From 24f9c8e99039253a747e625078dbff8437ec4c2a Mon Sep 17 00:00:00 2001 From: aaseenib Date: Fri, 6 Mar 2026 15:18:44 +0100 Subject: [PATCH 1/4] feat(mobile): add food details modal with animated reveal and swipe actions --- mobile/.env.example | 1 + mobile/app/(tabs)/discover.tsx | 333 ++++++++++++++++++++++++++++++++- mobile/package.json | 1 + mobile/src/lib/api.ts | 67 +++++++ 4 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 mobile/src/lib/api.ts diff --git a/mobile/.env.example b/mobile/.env.example index 86e2e4a..25bfdc2 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -1 +1,2 @@ EXPO_PUBLIC_API_BASE_URL=http://localhost:5000 +EXPO_PUBLIC_DEMO_USER_ID=660000000000000000000001 diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index ea9ccc5..60f84a1 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -1,9 +1,336 @@ -import { Text, View } from "react-native" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + ActivityIndicator, + Animated, + Modal, + Pressable, + StyleSheet, + Text, + TouchableWithoutFeedback, + View, +} from "react-native" +import { Image as ExpoImage } from "expo-image" +import { fetchDiscoverFeed, sendSwipe, type DiscoverItem } from "../../src/lib/api" + +const DISCOVERY_COORDINATES = { + longitude: -73.99, + latitude: 40.73, +} + +const PREFETCH_THRESHOLD = 3 export default function DiscoverScreen() { + const [cards, setCards] = useState([]) + const [cursor, setCursor] = useState(null) + const [loading, setLoading] = useState(true) + const [prefetching, setPrefetching] = useState(false) + const [selectedCard, setSelectedCard] = useState(null) + const [modalVisible, setModalVisible] = useState(false) + const modalAnim = 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 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) + }, + [prefetchImages], + ) + + useEffect(() => { + ;(async () => { + try { + await loadPage(null, false) + } finally { + setLoading(false) + } + })() + }, [loadPage]) + + const maybePrefetchNext = useCallback( + async (remaining: number) => { + if (remaining > PREFETCH_THRESHOLD || !cursor || prefetching) return + setPrefetching(true) + try { + await loadPage(cursor, true) + } finally { + setPrefetching(false) + } + }, + [cursor, loadPage, prefetching], + ) + + const hideModal = useCallback(() => { + Animated.timing(modalAnim, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }).start(() => { + setModalVisible(false) + setSelectedCard(null) + }) + }, [modalAnim]) + + const showModal = useCallback( + (card: DiscoverItem) => { + setSelectedCard(card) + setModalVisible(true) + modalAnim.setValue(0) + Animated.timing(modalAnim, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }).start() + }, + [modalAnim], + ) + + const handleSwipe = useCallback( + async (action: "like" | "pass") => { + const top = cards[0] + if (!top) return + + if (selectedCard && top.id === selectedCard.id) { + hideModal() + } + + setCards((prev) => prev.slice(1)) + void sendSwipe({ foodId: top.id, action }).catch(() => {}) + + const remaining = cards.length - 1 + await maybePrefetchNext(remaining) + }, + [cards, maybePrefetchNext, selectedCard, hideModal], + ) + + 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], + }) + + if (loading) { + return ( + + + Loading discovery feed... + + ) + } + + if (cards.length === 0) { + return ( + + No more items nearby + Try again in a moment. + + ) + } + return ( - - Swipe Stack Loading... + + + {stack + .map((item, index) => ({ item, index })) + .reverse() + .map(({ item, index }) => ( + showModal(item)} + style={[ + styles.card, + { + top: index * 10, + transform: [{ scale: 1 - index * 0.03 }], + }, + ]} + > + + + {item.name} + {item.restaurantName} + + ${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km + + + + ))} + + + + void handleSwipe("pass")}> + Pass + + void handleSwipe("like")}> + Like + + + + {prefetching ? Prefetching next cards... : null} + + + + + + + + {selectedCard ? ( + <> + {selectedCard.name} + {selectedCard.restaurantName} + + ${(selectedCard.price ?? 0).toFixed(2)} • {(selectedCard.distanceMeters / 1000).toFixed(1)}km + + {selectedCard.description} + + + void handleSwipe("pass")}> + Swipe Left + + void handleSwipe("like")}> + Swipe Right + + + + ) : null} + + ) } + +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, + }, + stackWrap: { + width: "100%", + maxWidth: 380, + height: 520, + position: "relative", + marginBottom: 20, + }, + card: { + position: "absolute", + width: "100%", + height: 500, + borderRadius: 20, + overflow: "hidden", + backgroundColor: "#fff", + shadowColor: "#000", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 6, + }, + image: { + width: "100%", + height: "78%", + }, + cardBody: { + padding: 14, + gap: 4, + }, + foodName: { + fontSize: 20, + fontWeight: "700", + }, + meta: { + color: "#555", + }, + actions: { + flexDirection: "row", + gap: 12, + }, + button: { + minWidth: 130, + paddingVertical: 14, + borderRadius: 14, + alignItems: "center", + }, + passBtn: { + backgroundColor: "#E53935", + }, + likeBtn: { + backgroundColor: "#2E7D32", + }, + buttonText: { + color: "#fff", + fontWeight: "700", + }, + modalBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#000", + }, + bottomSheet: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + backgroundColor: "#fff", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 28, + minHeight: 260, + }, + modalTitle: { + fontSize: 24, + fontWeight: "800", + marginBottom: 4, + }, + modalMeta: { + color: "#666", + marginBottom: 4, + }, + modalBody: { + marginTop: 12, + lineHeight: 22, + color: "#222", + }, + modalActions: { + marginTop: 20, + flexDirection: "row", + gap: 12, + }, +}) diff --git a/mobile/package.json b/mobile/package.json index 5fd4e06..95123bc 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "expo": "^51.0.0", + "expo-image": "^1.12.15", "expo-router": "^3.5.18", "expo-secure-store": "^13.0.2", "react": "18.2.0", diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts new file mode 100644 index 0000000..a14c3c2 --- /dev/null +++ b/mobile/src/lib/api.ts @@ -0,0 +1,67 @@ +import { useAuthStore } from "../store/useAuthStore" + +const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? "http://localhost:5000" +const DEMO_USER_ID = process.env.EXPO_PUBLIC_DEMO_USER_ID ?? "660000000000000000000001" + +export type DiscoverItem = { + id: string + restaurantId: string + name: string + description: string + price: number + imageUrl: string + restaurantName: string + distanceMeters: number +} + +type DiscoverResponse = { + items: DiscoverItem[] + cursor: string | null +} + +function getAuthHeaders() { + const token = useAuthStore.getState().token + return token ? { Authorization: `Bearer ${token}` } : {} +} + +export async function fetchDiscoverFeed(params: { + longitude: number + latitude: number + cursor?: string | null +}) { + const query = new URLSearchParams({ + longitude: String(params.longitude), + latitude: String(params.latitude), + }) + + if (params.cursor) query.set("cursor", params.cursor) + + const response = await fetch(`${API_BASE_URL}/api/foods/discover?${query.toString()}`, { + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch discover feed: ${response.status}`) + } + + return (await response.json()) as DiscoverResponse +} + +export async function sendSwipe(params: { foodId: string; action: "like" | "pass" }) { + const response = await fetch(`${API_BASE_URL}/api/swipe`, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + "x-user-id": DEMO_USER_ID, + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + throw new Error(`Failed to send swipe: ${response.status}`) + } +} From 7fd9b65a57a5c1e2df3b9c151dc19651dff824a0 Mon Sep 17 00:00:00 2001 From: aaseenib Date: Fri, 6 Mar 2026 18:11:52 +0100 Subject: [PATCH 2/4] refactor(mobile): extract animated food details sheet component --- mobile/app/(tabs)/discover.tsx | 82 ++-------------- mobile/src/components/FoodDetailsSheet.tsx | 108 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 72 deletions(-) create mode 100644 mobile/src/components/FoodDetailsSheet.tsx diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index 60f84a1..35888fb 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -2,15 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ActivityIndicator, Animated, - Modal, Pressable, StyleSheet, Text, - TouchableWithoutFeedback, 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, @@ -182,40 +181,15 @@ export default function DiscoverScreen() { {prefetching ? Prefetching next cards... : null} - - - - - - - {selectedCard ? ( - <> - {selectedCard.name} - {selectedCard.restaurantName} - - ${(selectedCard.price ?? 0).toFixed(2)} • {(selectedCard.distanceMeters / 1000).toFixed(1)}km - - {selectedCard.description} - - - void handleSwipe("pass")}> - Swipe Left - - void handleSwipe("like")}> - Swipe Right - - - - ) : null} - - + void handleSwipe("pass")} + onSwipeLike={() => void handleSwipe("like")} + translateY={modalTranslateY} + backdropOpacity={modalBackdropOpacity} + /> ) } @@ -297,40 +271,4 @@ const styles = StyleSheet.create({ color: "#fff", fontWeight: "700", }, - modalBackdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "#000", - }, - bottomSheet: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - backgroundColor: "#fff", - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 28, - minHeight: 260, - }, - modalTitle: { - fontSize: 24, - fontWeight: "800", - marginBottom: 4, - }, - modalMeta: { - color: "#666", - marginBottom: 4, - }, - modalBody: { - marginTop: 12, - lineHeight: 22, - color: "#222", - }, - modalActions: { - marginTop: 20, - flexDirection: "row", - gap: 12, - }, }) diff --git a/mobile/src/components/FoodDetailsSheet.tsx b/mobile/src/components/FoodDetailsSheet.tsx new file mode 100644 index 0000000..9959b28 --- /dev/null +++ b/mobile/src/components/FoodDetailsSheet.tsx @@ -0,0 +1,108 @@ +import { Animated, Modal, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from "react-native" +import type { DiscoverItem } from "../lib/api" + +type FoodDetailsSheetProps = { + visible: boolean + card: DiscoverItem | null + onClose: () => void + onSwipePass: () => void + onSwipeLike: () => void + translateY: Animated.AnimatedInterpolation + backdropOpacity: Animated.AnimatedInterpolation +} + +export function FoodDetailsSheet(props: FoodDetailsSheetProps) { + const { visible, card, onClose, onSwipePass, onSwipeLike, translateY, backdropOpacity } = props + + return ( + + + + + + + {card ? ( + <> + {card.name} + {card.restaurantName} + + ${card.price.toFixed(2)} • {(card.distanceMeters / 1000).toFixed(1)}km + + {card.description} + + + + Swipe Left + + + Swipe Right + + + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#000", + }, + bottomSheet: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + backgroundColor: "#fff", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 28, + minHeight: 260, + }, + modalTitle: { + fontSize: 24, + fontWeight: "800", + marginBottom: 4, + }, + modalMeta: { + color: "#666", + marginBottom: 4, + }, + modalBody: { + marginTop: 12, + lineHeight: 22, + color: "#222", + }, + modalActions: { + marginTop: 20, + flexDirection: "row", + gap: 12, + }, + button: { + minWidth: 130, + paddingVertical: 14, + borderRadius: 14, + alignItems: "center", + }, + passBtn: { + backgroundColor: "#E53935", + }, + likeBtn: { + backgroundColor: "#2E7D32", + }, + buttonText: { + color: "#fff", + fontWeight: "700", + }, +}) From 326b939edabc6d5baa37f24c604e6c3b4b9fa9ec Mon Sep 17 00:00:00 2001 From: aaseenib Date: Sat, 7 Mar 2026 11:34:26 +0100 Subject: [PATCH 3/4] docs(mobile): add food details modal interaction and behavior notes --- mobile/README.md | 2 ++ mobile/docs/food-details-modal.md | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 mobile/docs/food-details-modal.md diff --git a/mobile/README.md b/mobile/README.md index 038d2ab..8953499 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -27,3 +27,5 @@ npm run start - Deep link scheme is `discoverly`. - EAS profiles are defined in `eas.json`. +- Discover screen now includes a food detail modal (bottom sheet). +- Modal actions trigger the same swipe API flow as main pass/like buttons. diff --git a/mobile/docs/food-details-modal.md b/mobile/docs/food-details-modal.md new file mode 100644 index 0000000..33d36fd --- /dev/null +++ b/mobile/docs/food-details-modal.md @@ -0,0 +1,25 @@ +# Food Details Modal (Issue 2.6) + +## Behavior + +- Tapping a card opens an animated bottom-sheet modal. +- Modal displays: + - food name + - restaurant name + - distance + - price + - full description +- Modal can be dismissed by tapping the backdrop. + +## Swipe Actions In Modal + +- `Swipe Left` and `Swipe Right` buttons are available in the modal. +- Pressing either button: + 1. closes the modal + 2. advances/removes the top card + 3. triggers `POST /api/swipe` in background + +## Notes + +- Discover list uses the same in-memory queue as modal actions. +- Image rendering uses `expo-image`. From 83b671bd06ee08e3b6181160f115dbe198c8feee Mon Sep 17 00:00:00 2001 From: aaseenib Date: Sat, 7 Mar 2026 16:25:39 +0100 Subject: [PATCH 4/4] feat(mobile): implement swipe gestures for food details modal --- mobile/src/components/FoodDetailsSheet.tsx | 64 +++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/mobile/src/components/FoodDetailsSheet.tsx b/mobile/src/components/FoodDetailsSheet.tsx index 91ccc39..10e193f 100644 --- a/mobile/src/components/FoodDetailsSheet.tsx +++ b/mobile/src/components/FoodDetailsSheet.tsx @@ -1,4 +1,14 @@ -import { Animated, Modal, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from "react-native" +import { useEffect, useMemo, useRef } from "react" +import { + Animated, + Modal, + PanResponder, + Pressable, + StyleSheet, + Text, + TouchableWithoutFeedback, + View, +} from "react-native" import type { DiscoverItem } from "../lib/api" type FoodDetailsSheetProps = { @@ -14,6 +24,49 @@ type FoodDetailsSheetProps = { export function FoodDetailsSheet(props: FoodDetailsSheetProps) { const { visible, card, onClose, onSwipePass, onSwipeLike, translateY, backdropOpacity } = props const XLM_PER_USD = 4.2 + const swipeX = useRef(new Animated.Value(0)).current + const swipeTriggered = useRef(false) + const SWIPE_THRESHOLD = 90 + + useEffect(() => { + swipeTriggered.current = false + swipeX.setValue(0) + }, [visible, card, swipeX]) + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_evt, gestureState) => + Math.abs(gestureState.dx) > 12 && Math.abs(gestureState.dx) > Math.abs(gestureState.dy), + onPanResponderMove: (_evt, gestureState) => { + swipeX.setValue(gestureState.dx) + }, + onPanResponderRelease: (_evt, gestureState) => { + if (swipeTriggered.current) { + return + } + + if (gestureState.dx > SWIPE_THRESHOLD) { + swipeTriggered.current = true + onSwipeLike() + return + } + + if (gestureState.dx < -SWIPE_THRESHOLD) { + swipeTriggered.current = true + onSwipePass() + return + } + + Animated.spring(swipeX, { + toValue: 0, + useNativeDriver: true, + bounciness: 6, + }).start() + }, + }), + [onSwipeLike, onSwipePass, swipeX], + ) return ( @@ -22,10 +75,11 @@ export function FoodDetailsSheet(props: FoodDetailsSheetProps) { @@ -38,6 +92,7 @@ export function FoodDetailsSheet(props: FoodDetailsSheetProps) { {(card.distanceMeters / 1000).toFixed(1)}km {card.description} + Swipe left to pass, right to like @@ -86,6 +141,11 @@ const styles = StyleSheet.create({ lineHeight: 22, color: "#222", }, + swipeHint: { + marginTop: 10, + color: "#667085", + fontSize: 12, + }, modalActions: { marginTop: 20, flexDirection: "row",