Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
177 changes: 151 additions & 26 deletions mobile/app/(tabs)/discover.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
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,
latitude: 40.73,
}

const PREFETCH_THRESHOLD = 3
const SWIPE_OUT_DISTANCE = Dimensions.get("window").width + 80

export default function DiscoverScreen() {
const [cards, setCards] = useState<DiscoverItem[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [prefetching, setPrefetching] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedCard, setSelectedCard] = useState<DiscoverItem | null>(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)
Expand All @@ -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)
Expand Down Expand Up @@ -79,32 +93,96 @@ 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)
}
},
[cursor, loadPage, prefetching],
)

const hideModal = useCallback(() => {
return new Promise<void>((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<void>((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]
if (!top) {
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 (
<View style={styles.center}>
Expand Down Expand Up @@ -141,27 +219,40 @@ export default function DiscoverScreen() {
{stack
.map((item, index) => ({ item, index }))
.reverse()
.map(({ item, index }) => (
<View
key={item.id}
style={[
styles.card,
{
top: index * 10,
transform: [{ scale: 1 - index * 0.03 }],
},
]}
>
<ExpoImage source={item.imageUrl} style={styles.image} contentFit="cover" />
<View style={styles.cardBody}>
<Text style={styles.foodName}>{item.name}</Text>
<Text style={styles.meta}>{item.restaurantName}</Text>
<Text style={styles.meta}>
${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km
</Text>
</View>
</View>
))}
.map(({ item, index }) => {
const isTop = index === 0
const CardContainer = isTop ? Animated.View : View

return (
<CardContainer
key={item.id}
style={[
styles.card,
{
top: index * 10,
transform: [
{ scale: 1 - index * 0.03 },
...(isTop ? [{ translateX: topCardX }, { rotate: topCardRotate }] : []),
],
},
]}
>
<Pressable style={styles.cardTap} onPress={() => showModal(item)}>
<ExpoImage source={item.imageUrl} style={styles.image} contentFit="cover" />
<View style={styles.cardBody}>
<Text style={styles.foodName}>{item.name}</Text>
<Text style={styles.meta}>{item.restaurantName}</Text>
<Text style={styles.meta}>
${item.price.toFixed(2)} • {(item.distanceMeters / 1000).toFixed(1)}km
</Text>
</View>
</Pressable>
<Pressable style={styles.infoButton} onPress={() => showModal(item)}>
<Text style={styles.infoButtonText}>Info</Text>
</Pressable>
</CardContainer>
)
})}
</View>

<View style={styles.actions}>
Expand All @@ -174,6 +265,22 @@ export default function DiscoverScreen() {
</View>

{prefetching ? <Text style={styles.caption}>Prefetching next cards...</Text> : null}

<FoodDetailsSheet
visible={modalVisible}
card={selectedCard}
onClose={() => {
void hideModal()
}}
onSwipePass={() => {
void handleSwipe("pass")
}}
onSwipeLike={() => {
void handleSwipe("like")
}}
translateY={modalTranslateY}
backdropOpacity={modalBackdropOpacity}
/>
</View>
)
}
Expand All @@ -198,6 +305,7 @@ const styles = StyleSheet.create({
},
caption: {
color: "#666",
marginTop: 10,
},
stackWrap: {
width: "100%",
Expand All @@ -219,6 +327,9 @@ const styles = StyleSheet.create({
shadowRadius: 12,
elevation: 6,
},
cardTap: {
flex: 1,
},
image: {
width: "100%",
height: "78%",
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions mobile/docs/food-details-modal.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading