diff --git a/mobile/README.md b/mobile/README.md index b299233..b2d81e4 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -27,6 +27,8 @@ 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. - Discovery tab pulls from `GET /api/foods/discover`. - Swipe actions post to `POST /api/swipe`. - Feed prefetch starts when only 3 cards remain. diff --git a/mobile/app/(tabs)/discover.tsx b/mobile/app/(tabs)/discover.tsx index b1568e6..13c0be6 100644 --- a/mobile/app/(tabs)/discover.tsx +++ b/mobile/app/(tabs)/discover.tsx @@ -1,7 +1,16 @@ -import { useCallback, useEffect, useMemo, useState } from "react" -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native" +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, @@ -9,6 +18,7 @@ const DISCOVERY_COORDINATES = { } const PREFETCH_THRESHOLD = 3 +const SWIPE_OUT_DISTANCE = Dimensions.get("window").width + 80 export default function DiscoverScreen() { const [cards, setCards] = useState([]) @@ -16,6 +26,11 @@ export default function DiscoverScreen() { 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) @@ -32,7 +47,6 @@ export default function DiscoverScreen() { }) await prefetchImages(result.items) - setCards((prev) => (append ? [...prev, ...result.items] : result.items)) setCursor(result.cursor) setLoadError(null) @@ -79,7 +93,7 @@ export default function DiscoverScreen() { try { await loadPage(cursor, true) } catch { - // Keep current stack when prefetch fails; user can continue swiping. + // Keep current cards available if prefetch fails. } finally { setPrefetching(false) } @@ -87,6 +101,52 @@ export default function DiscoverScreen() { [cursor, loadPage, prefetching], ) + const hideModal = useCallback(() => { + return new Promise((resolve) => { + if (!modalVisible) { + resolve() + return + } + + Animated.timing(modalAnim, { + toValue: 0, + duration: 180, + useNativeDriver: true, + }).start(() => { + setModalVisible(false) + setSelectedCard(null) + resolve() + }) + }) + }, [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 handleSwipe = useCallback( async (action: "like" | "pass") => { const top = cards[0] @@ -94,17 +154,35 @@ export default function DiscoverScreen() { return } + 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) }, - [cards, maybePrefetchNext], + [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 ( @@ -141,27 +219,40 @@ export default function DiscoverScreen() { {stack .map((item, index) => ({ item, index })) .reverse() - .map(({ item, index }) => ( - - - - {item.name} - {item.restaurantName} - - ${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km - - - - ))} + .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 + + + ) + })} @@ -174,6 +265,22 @@ export default function DiscoverScreen() { {prefetching ? Prefetching next cards... : null} + + { + void hideModal() + }} + onSwipePass={() => { + void handleSwipe("pass") + }} + onSwipeLike={() => { + void handleSwipe("like") + }} + translateY={modalTranslateY} + backdropOpacity={modalBackdropOpacity} + /> ) } @@ -198,6 +305,7 @@ const styles = StyleSheet.create({ }, caption: { color: "#666", + marginTop: 10, }, stackWrap: { width: "100%", @@ -219,6 +327,9 @@ const styles = StyleSheet.create({ shadowRadius: 12, elevation: 6, }, + cardTap: { + flex: 1, + }, image: { width: "100%", height: "78%", @@ -234,6 +345,20 @@ const styles = StyleSheet.create({ meta: { color: "#555", }, + infoButton: { + position: "absolute", + right: 12, + top: 12, + backgroundColor: "rgba(17,24,39,0.75)", + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + }, + infoButtonText: { + color: "#fff", + fontWeight: "700", + fontSize: 12, + }, actions: { flexDirection: "row", gap: 12, 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`. diff --git a/mobile/src/components/FoodDetailsSheet.tsx b/mobile/src/components/FoodDetailsSheet.tsx new file mode 100644 index 0000000..10e193f --- /dev/null +++ b/mobile/src/components/FoodDetailsSheet.tsx @@ -0,0 +1,170 @@ +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 = { + 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 + 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 ( + + + + + + + {card ? ( + <> + {card.name} + {card.restaurantName} + + ${card.price.toFixed(2)} • {(card.price * XLM_PER_USD).toFixed(2)} XLM •{" "} + {(card.distanceMeters / 1000).toFixed(1)}km + + {card.description} + Swipe left to pass, right to like + + + + 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", + }, + swipeHint: { + marginTop: 10, + color: "#667085", + fontSize: 12, + }, + 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", + }, +})