Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions mobile/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
EXPO_PUBLIC_API_BASE_URL=http://localhost:5000
EXPO_PUBLIC_DEMO_USER_ID=660000000000000000000001
2 changes: 2 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
271 changes: 268 additions & 3 deletions mobile/app/(tabs)/discover.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,274 @@
import { Text, View } from "react-native"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ActivityIndicator,
Animated,
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

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 [selectedCard, setSelectedCard] = useState<DiscoverItem | null>(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(() => {})
Comment on lines +150 to +161
Copy link
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, 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 (
<View style={styles.center}>
<ActivityIndicator />
<Text style={styles.caption}>Loading discovery feed...</Text>
</View>
)
}

if (cards.length === 0) {
return (
<View style={styles.center}>
<Text style={styles.title}>No more items nearby</Text>
<Text style={styles.caption}>Try again in a moment.</Text>
</View>
)
}

return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<Text>Swipe Stack Loading...</Text>
<View style={styles.container}>
<View style={styles.stackWrap}>
{stack
.map((item, index) => ({ item, index }))
.reverse()
.map(({ item, index }) => (
<Pressable
key={item.id}
onPress={() => showModal(item)}
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>
</Pressable>
))}
</View>

<View style={styles.actions}>
<Pressable style={[styles.button, styles.passBtn]} onPress={() => void handleSwipe("pass")}>
<Text style={styles.buttonText}>Pass</Text>
</Pressable>
<Pressable style={[styles.button, styles.likeBtn]} onPress={() => void handleSwipe("like")}>
<Text style={styles.buttonText}>Like</Text>
</Pressable>
</View>

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

<FoodDetailsSheet
visible={modalVisible}
card={selectedCard}
onClose={hideModal}
onSwipePass={() => void handleSwipe("pass")}
onSwipeLike={() => void handleSwipe("like")}
translateY={modalTranslateY}
backdropOpacity={modalBackdropOpacity}
/>
</View>
)
}

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",
},
})
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`.
1 change: 1 addition & 0 deletions mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading