Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ app-example
# generated native folders
/ios
/android
.env
231 changes: 125 additions & 106 deletions app/(tabs)/explore.tsx
Original file line number Diff line number Diff line change
@@ -1,109 +1,120 @@
import * as Location from 'expo-location';
import { router, useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import MapView, { Callout, Marker, PROVIDER_DEFAULT } from 'react-native-maps';
import * as Location from "expo-location";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import MapView, { Callout, Marker, PROVIDER_DEFAULT } from "react-native-maps";

import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { FoodLocation, foodLocations } from '@/constants/locations';
import { formatDistance, getDistance } from '@/utils/distance';
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { FoodLocation, foodLocations } from "@/constants/locations";
import { formatDistance, getDistance } from "@/utils/distance";
import {
categorizePlace,
formatOSMAddress,
onOSMCacheCleared,
searchNearbyFoodLocations,
} from '@/utils/osm-api';
} from "@/utils/osm-api";

const STALE_MS = 5 * 60 * 1000;

export default function TabTwoScreen() {
const [locations, setLocations] = useState<FoodLocation[]>(foodLocations);
const [loading, setLoading] = useState(true);
const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null);
const [userLocation, setUserLocation] = useState<{
latitude: number;
longitude: number;
} | null>(null);
const lastLoadedRef = useRef<number>(0);
const hasLoadedRef = useRef<boolean>(false);

const loadLocations = useCallback(async (opts?: { force?: boolean }) => {
const isStale = Date.now() - lastLoadedRef.current > STALE_MS;
if (!opts?.force && hasLoadedRef.current && !isStale) return;
const loadLocations = useCallback(
async (opts?: { force?: boolean }) => {
const isStale = Date.now() - lastLoadedRef.current > STALE_MS;
if (!opts?.force && hasLoadedRef.current && !isStale) return;

// When forcing or first load with empty data, show spinner
if ((!hasLoadedRef.current || opts?.force) && locations.length === 0) setLoading(true);
// When forcing or first load with empty data, show spinner
if ((!hasLoadedRef.current || opts?.force) && locations.length === 0)
setLoading(true);

try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
setLoading(false);
return;
}
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
setLoading(false);
return;
}

const location = await Location.getCurrentPositionAsync({});
setUserLocation({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
});
const location = await Location.getCurrentPositionAsync({});
setUserLocation({
latitude: location.coords.latitude,
longitude: location.coords.longitude,
});

console.log('Fetching OSM data...');
// Fetch real data from OSM
const osmPlaces = await searchNearbyFoodLocations(
location.coords.latitude,
location.coords.longitude,
undefined,
opts?.force ? { force: true } : undefined
);
console.log("Fetching OSM data...");
// Fetch real data from OSM
const osmPlaces = await searchNearbyFoodLocations(
location.coords.latitude,
location.coords.longitude,
10,
opts?.force ? { force: true } : undefined
);

console.log(`Found ${osmPlaces.length} OSM places`);
console.log(`Found ${osmPlaces.length} OSM places`);

if (osmPlaces.length > 0) {
const mappedLocations: FoodLocation[] = osmPlaces.map((place, index) => ({
id: place.place_id || `osm-${index}`,
name: place.display_name.split(',')[0],
address: formatOSMAddress(place),
type: categorizePlace(place),
coordinate: {
latitude: parseFloat(place.lat),
longitude: parseFloat(place.lon),
},
distance: formatDistance(
getDistance(
location.coords.latitude,
location.coords.longitude,
parseFloat(place.lat),
parseFloat(place.lon)
)
),
}));
if (osmPlaces.length > 0) {
const mappedLocations: FoodLocation[] = osmPlaces.map(
(place, index) => ({
id: place.place_id || `osm-${index}`,
name: place.display_name.split(",")[0],
address: formatOSMAddress(place),
type: categorizePlace(place),
coordinate: {
latitude: parseFloat(place.lat),
longitude: parseFloat(place.lon),
},
distance: formatDistance(
getDistance(
location.coords.latitude,
location.coords.longitude,
parseFloat(place.lat),
parseFloat(place.lon)
)
),
})
);

setLocations(mappedLocations);
} else {
// Fall back to static list with computed distances so we always show something
const withDistances = foodLocations.map((loc) => ({
...loc,
calculatedDistance: getDistance(
location.coords.latitude,
location.coords.longitude,
loc.coordinate.latitude,
loc.coordinate.longitude
),
}));
const sorted = withDistances.sort((a, b) => a.calculatedDistance - b.calculatedDistance);
setLocations(
sorted.map((loc) => ({
setLocations(mappedLocations);
} else {
// Fall back to static list with computed distances so we always show something
const withDistances = foodLocations.map((loc) => ({
...loc,
distance: formatDistance(loc.calculatedDistance),
}))
);
calculatedDistance: getDistance(
location.coords.latitude,
location.coords.longitude,
loc.coordinate.latitude,
loc.coordinate.longitude
),
}));
const sorted = withDistances.sort(
(a, b) => a.calculatedDistance - b.calculatedDistance
);
setLocations(
sorted.map((loc) => ({
...loc,
distance: formatDistance(loc.calculatedDistance),
}))
);
}
} catch (error) {
console.error("Error loading locations:", error);
// Fall back to static data
} finally {
hasLoadedRef.current = true;
lastLoadedRef.current = Date.now();
setLoading(false);
}
} catch (error) {
console.error('Error loading locations:', error);
// Fall back to static data
} finally {
hasLoadedRef.current = true;
lastLoadedRef.current = Date.now();
setLoading(false);
}
}, [locations]);
},
[locations]
);

useFocusEffect(
useCallback(() => {
Expand All @@ -123,21 +134,19 @@ export default function TabTwoScreen() {
return unsubscribe;
}, [loadLocations]);

const mapRegion = userLocation || {
latitude: 33.7676,
longitude: -84.3908,
const mapRegion = {
latitude: userLocation?.latitude ?? 33.7676,
longitude: userLocation?.longitude ?? -84.3908,
latitudeDelta: 0.15,
longitudeDelta: 0.15,
};

return (
<View style={styles.container}>
<MapView
style={styles.map}
provider={PROVIDER_DEFAULT}
initialRegion={{
...mapRegion,
latitudeDelta: 0.15,
longitudeDelta: 0.15,
}}
region={mapRegion}
showsUserLocation
showsMyLocationButton
showsCompass
Expand All @@ -151,7 +160,7 @@ export default function TabTwoScreen() {
<Callout
onPress={() => {
router.push({
pathname: '/option/[id]',
pathname: "/option/[id]",
params: {
id: location.id,
name: location.name,
Expand All @@ -163,26 +172,36 @@ export default function TabTwoScreen() {
}}
>
<View style={styles.calloutContainer}>
<ThemedText style={styles.calloutTitle}>{location.name}</ThemedText>
<ThemedText style={styles.calloutTitle}>
{location.name}
</ThemedText>
<View style={styles.calloutBadge}>
<ThemedText style={styles.calloutBadgeText}>{location.type}</ThemedText>
<ThemedText style={styles.calloutBadgeText}>
{location.type}
</ThemedText>
</View>
{location.distance && (
<ThemedText style={styles.calloutDistance}>{location.distance}</ThemedText>
<ThemedText style={styles.calloutDistance}>
{location.distance}
</ThemedText>
)}
<ThemedText style={styles.calloutTap}>Tap for details →</ThemedText>
<ThemedText style={styles.calloutTap}>
Tap for details →
</ThemedText>
</View>
</Callout>
</Marker>
))}
</MapView>

<ThemedView style={styles.floatingHeader}>
<ThemedText type="title" style={styles.headerTitle}>
Explore
</ThemedText>
<ThemedText style={styles.headerSubtitle}>
{loading ? 'Loading nearby options...' : `${locations.length} food options nearby`}
{loading
? "Loading nearby options..."
: `${locations.length} food options nearby`}
</ThemedText>
{loading && <ActivityIndicator size="small" style={{ marginTop: 8 }} />}
</ThemedView>
Expand All @@ -198,14 +217,14 @@ const styles = StyleSheet.create({
flex: 1,
},
floatingHeader: {
position: 'absolute',
position: "absolute",
top: 60,
left: 20,
right: 20,
paddingVertical: 16,
paddingHorizontal: 20,
borderRadius: 16,
shadowColor: '#000',
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
Expand All @@ -226,22 +245,22 @@ const styles = StyleSheet.create({
gap: 6,
},
calloutTitle: {
fontWeight: '700',
fontWeight: "700",
fontSize: 16,
marginBottom: 4,
},
calloutBadge: {
backgroundColor: '#e0f2fe',
backgroundColor: "#e0f2fe",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
alignSelf: 'flex-start',
alignSelf: "flex-start",
marginBottom: 2,
},
calloutBadgeText: {
color: '#0369a1',
color: "#0369a1",
fontSize: 12,
fontWeight: '600',
fontWeight: "600",
},
calloutDistance: {
fontSize: 13,
Expand All @@ -250,8 +269,8 @@ const styles = StyleSheet.create({
},
calloutTap: {
fontSize: 12,
color: '#2563eb',
fontWeight: '700',
color: "#2563eb",
fontWeight: "700",
marginTop: 6,
},
});
Loading