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
4 changes: 4 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ npm run start

- Deep link scheme is `discoverly`.
- EAS profiles are defined in `eas.json`.
- Discovery tab pulls from `GET /api/foods/discover`.
- Swipe actions post to `POST /api/swipe`.
- Feed prefetch starts when only 3 cards remain.
- `expo-image` is used for image caching/prefetch to reduce flicker.
221 changes: 218 additions & 3 deletions mobile/app/(tabs)/discover.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,224 @@
import { Text, View } from "react-native"
import { useCallback, useEffect, useMemo, useState } from "react"
import { ActivityIndicator, Pressable, StyleSheet, Text, 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<DiscoverItem[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [prefetching, setPrefetching] = useState(false)

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(() => {
let active = true
;(async () => {
try {
await loadPage(null, false)
} finally {
if (active) setLoading(false)
}
})()

return () => {
active = 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 handleSwipe = useCallback(
async (action: "like" | "pass") => {
const top = cards[0]
if (!top) return

setCards((prev) => prev.slice(1))
void sendSwipe({ foodId: top.id, action }).catch(() => {})

const remaining = cards.length - 1
await maybePrefetchNext(remaining)
},
[cards, maybePrefetchNext],
)

const stack = useMemo(() => cards.slice(0, 3), [cards])

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 }) => (
<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>
))}
</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}
</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",
},
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",
},
})
22 changes: 22 additions & 0 deletions mobile/docs/discovery-feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Discovery Feed Integration (Issue 2.5)

## APIs

- `GET /api/foods/discover`
- `POST /api/swipe`

## Prefetch Strategy

- Maintain a local card queue.
- After each swipe, if remaining cards are `<= 3`, fetch the next cursor page in background.
- Append next-page cards without blocking user interaction.

## Image Caching

- Use `expo-image` and run `Image.prefetch(urls)` for each fetched page.
- Render card images via `expo-image` with `contentFit="cover"`.

## Environment Variables

- `EXPO_PUBLIC_API_BASE_URL`
- `EXPO_PUBLIC_DEMO_USER_ID` (temporary user header for current backend integration)
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
69 changes: 69 additions & 0 deletions mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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}`)
}
}
Loading