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(() => {})
Comment on lines +150 to +161
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure modal swipe actions only act on the currently selected card (to prevent stale-selection mismatch if top card changed before modal action), please review the food details modal implementation for interaction correctness and race-safety.

Focus on:

  • whether modal swipe actions always target the intended card
  • stale state risks between selected modal item and top stack item
  • modal close/open animation consistency under rapid interactions
  • API side-effects when modal and main swipe controls are used quickly


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