refactor: use react-query to fetch profile and food data

This commit is contained in:
Sosokker 2025-05-10 02:53:46 +07:00
parent c03746bb96
commit 4f465fd617
5 changed files with 226 additions and 122 deletions

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { Image } from "expo-image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Image,
ScrollView, ScrollView,
Text, Text,
TouchableOpacity, TouchableOpacity,
@ -12,98 +12,64 @@ import {
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "@/context/auth-context"; import { useAuth } from "@/context/auth-context";
import { getFoods } from "@/services/data/foods";
import { getProfile } from "@/services/data/profile"; import { getProfile } from "@/services/data/profile";
import { supabase } from "@/services/supabase"; import { supabase } from "@/services/supabase";
import { useIsFocused } from "@react-navigation/native";
import { useQuery } from "@tanstack/react-query";
export default function ProfileScreen() { export default function ProfileScreen() {
const [activeTab, setActiveTab] = useState("Repost"); const [activeTab, setActiveTab] = useState("My Recipes");
const [username, setUsername] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const isFocused = useIsFocused();
const [userId, setUserId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchProfile = async () => { let ignore = false;
setLoading(true); async function getUser() {
setError(null); const { data } = await supabase.auth.getUser();
try { if (!ignore) {
const { data: userData, error: userError } = setUserId(data?.user?.id ?? null);
await supabase.auth.getUser();
if (userError || !userData?.user?.id) {
setError("Unable to get user info.");
setUsername(null);
setLoading(false);
return;
} }
const { data, error } = await getProfile(userData.user.id);
if (error) {
setError(error.message || "Failed to fetch profile");
setUsername(null);
} else {
setUsername(data?.username || null);
}
} catch (e: any) {
setError(e.message || "Unknown error");
setUsername(null);
} finally {
setLoading(false);
} }
if (isAuthenticated) getUser();
else setUserId(null);
return () => {
ignore = true;
}; };
if (isAuthenticated) {
fetchProfile();
}
}, [isAuthenticated]); }, [isAuthenticated]);
const foodItems = [ const {
{ data: profileData,
id: 1, error,
name: "Padthaipro", isLoading,
image: require("@/assets/images/food/padthai.jpg"), } = useQuery({
color: "#FFCC00", queryKey: ["profile", userId],
queryFn: async () => {
if (!userId) throw new Error("No user id");
return getProfile(userId);
}, },
{ enabled: !!userId,
id: 2, subscribed: isFocused,
name: "Jjajangmyeon", });
image: require("@/assets/images/food/jjajangmyeon.jpg"),
color: "#FFA500", // Fetch user's foods for 'My Recipes'
const {
data: foodsData,
isLoading: isFoodsLoading,
error: foodsError,
} = useQuery({
queryKey: ["my-recipes", userId],
queryFn: async () => {
if (!userId) throw new Error("No user id");
return getFoods(userId);
}, },
{ enabled: !!userId && activeTab === "My Recipes",
id: 3, subscribed: isFocused && activeTab === "My Recipes",
name: "Wingztab", });
image: require("@/assets/images/food/wings.jpg"),
color: "#FFCC00", // Remove static foodItems, use foodsData for My Recipes
},
{
id: 4,
name: "Ramen",
image: require("@/assets/images/food/ramen.jpg"),
color: "#FFA500",
},
{
id: 5,
name: "Tiramisu",
image: require("@/assets/images/food/tiramisu.jpg"),
color: "#FFCC00",
},
{
id: 6,
name: "Beef wellington",
image: require("@/assets/images/food/beef.jpg"),
color: "#FFA500",
},
{
id: 7,
name: "Tiramisu",
image: require("@/assets/images/food/tiramisu.jpg"),
color: "#FFCC00",
},
{
id: 8,
name: "Beef wellington",
image: require("@/assets/images/food/beef.jpg"),
color: "#FFA500",
},
];
return ( return (
<SafeAreaView className="flex-1 bg-white" edges={["top"]}> <SafeAreaView className="flex-1 bg-white" edges={["top"]}>
@ -115,16 +81,20 @@ export default function ProfileScreen() {
<Text className="text-5xl">👨🍳</Text> <Text className="text-5xl">👨🍳</Text>
</View> </View>
</View> </View>
{loading ? ( {isLoading ? (
<ActivityIndicator <ActivityIndicator
size="small" size="small"
color="#bb0718" color="#bb0718"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
/> />
) : error ? ( ) : error ? (
<Text className="text-xl font-bold mb-3 text-red-600">{error}</Text> <Text className="text-xl font-bold mb-3 text-red-600">
{error.message || error.toString()}
</Text>
) : ( ) : (
<Text className="text-xl font-bold mb-3">{username ?? "-"}</Text> <Text className="text-xl font-bold mb-3">
{profileData?.data?.username ?? "-"}
</Text>
)} )}
<TouchableOpacity className="bg-red-600 py-2 px-10 rounded-lg"> <TouchableOpacity className="bg-red-600 py-2 px-10 rounded-lg">
<Text className="text-white font-bold">Edit</Text> <Text className="text-white font-bold">Edit</Text>
@ -133,7 +103,7 @@ export default function ProfileScreen() {
{/* Tab Navigation */} {/* Tab Navigation */}
<View className="flex-row justify-around py-3"> <View className="flex-row justify-around py-3">
{["Repost", "Likes", "Bookmark"].map((tab) => ( {["My Recipes", "Likes", "Saved"].map((tab) => (
<TouchableOpacity <TouchableOpacity
key={tab} key={tab}
className={`py-2 px-4 ${ className={`py-2 px-4 ${
@ -148,26 +118,54 @@ export default function ProfileScreen() {
<View className="h-px bg-[#EEEEEE] mx-4" /> <View className="h-px bg-[#EEEEEE] mx-4" />
{/* Food Grid */} {/* Food Grid / Tab Content */}
{activeTab === "My Recipes" && (
<View className="flex-row flex-wrap p-2"> <View className="flex-row flex-wrap p-2">
{foodItems.map((item, index) => ( {isFoodsLoading ? (
<View key={`${item.id}-${index}`} className="w-1/2 p-2 relative"> <ActivityIndicator
<Image size="small"
source={item.image} color="#bb0718"
className="w-full h-[120px] rounded-lg" style={{ marginTop: 20 }}
resizeMode="cover"
/> />
<View ) : foodsError ? (
className="absolute bottom-4 left-4 py-1 px-2 rounded bg-opacity-90" <Text className="text-red-600 font-bold p-4">
style={{ backgroundColor: item.color }} {foodsError.message || foodsError.toString()}
> </Text>
) : foodsData?.data?.length ? (
foodsData.data.map((item, index) => (
<View key={item.id} className="w-1/2 p-2 relative">
<Image
source={
item.image_url
? { uri: item.image_url }
: require("@/assets/images/placeholder-food.jpg")
}
className="w-full h-[120px] rounded-lg"
/>
<View className="absolute bottom-4 left-4 py-1 px-2 rounded bg-opacity-90 bg-white/80">
<Text className="text-[#333] font-bold text-xs"> <Text className="text-[#333] font-bold text-xs">
{item.name} {item.name}
</Text> </Text>
</View> </View>
</View> </View>
))} ))
) : (
<Text className="text-gray-400 font-bold p-4">
No recipes found.
</Text>
)}
</View> </View>
)}
{activeTab === "Likes" && (
<Text className="text-gray-400 font-bold p-4">
Liked recipes will appear here.
</Text>
)}
{activeTab === "Saved" && (
<Text className="text-gray-400 font-bold p-4">
Saved recipes will appear here.
</Text>
)}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );

View File

@ -1,11 +1,15 @@
import { AuthProvider } from "@/context/auth-context"; import { AuthProvider } from "@/context/auth-context";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "../global.css"; import "../global.css";
const queryClient = new QueryClient();
export default function RootLayout() { export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen <Stack.Screen
@ -23,6 +27,7 @@ export default function RootLayout() {
/> />
</Stack> </Stack>
</AuthProvider> </AuthProvider>
</QueryClientProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }

45
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "chefhai", "name": "chefhai",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dev-plugins/react-query": "^0.3.1",
"@expo/ngrok": "^4.1.3", "@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "2.1.2", "@react-native-async-storage/async-storage": "2.1.2",
@ -1439,6 +1440,19 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dev-plugins/react-query": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@dev-plugins/react-query/-/react-query-0.3.1.tgz",
"integrity": "sha512-xmNfMIwHLhigE/usbGma+W/2G8bHRAfrP83nqTtjPMc7Rt2zSv1Ju3EK3KhkAh3WuHrUMpBIbNJdLgSc+K4tMg==",
"license": "MIT",
"dependencies": {
"flatted": "^3.3.1"
},
"peerDependencies": {
"@tanstack/react-query": "*",
"expo": "^53.0.5"
}
},
"node_modules/@egjs/hammerjs": { "node_modules/@egjs/hammerjs": {
"version": "2.0.17", "version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@ -3280,6 +3294,34 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.75.7",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.75.7.tgz",
"integrity": "sha512-4BHu0qnxUHOSnTn3ow9fIoBKTelh0GY08yn1IO9cxjBTsGvnxz1ut42CHZqUE3Vl/8FAjcHsj8RNJMoXvjgHEA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.75.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.75.7.tgz",
"integrity": "sha512-JYcH1g5pNjKXNQcvvnCU/PueaYg05uKBDHlWIyApspv7r5C0BM12n6ysa2QF2T+1tlPnNXOob8vr8o96Nx0GxQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.75.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@ -7041,8 +7083,7 @@
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="
"dev": true
}, },
"node_modules/flow-enums-runtime": { "node_modules/flow-enums-runtime": {
"version": "0.0.6", "version": "0.0.6",

View File

@ -11,6 +11,7 @@
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@dev-plugins/react-query": "^0.3.1",
"@expo/ngrok": "^4.1.3", "@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@react-native-async-storage/async-storage": "2.1.2", "@react-native-async-storage/async-storage": "2.1.2",

59
services/data/foods.ts Normal file
View File

@ -0,0 +1,59 @@
import { supabase } from "@/services/supabase";
import { PostgrestError } from "@supabase/supabase-js";
interface Foods {
id: string;
name: string;
description?: string;
time_to_cook_minutes: number;
skill_level: "Easy" | "Medium" | "Hard";
ingredient_count?: number;
calories?: number;
image_url?: string;
is_shared: boolean;
created_by: string;
created_at: string;
}
/**
* Retrieves a list of foods based on the provided filters.
*
* @param userId - The ID of the user to filter foods by.
* @param isShared - Whether to filter foods by shared status.
* @param search - The search query to filter foods by name or description.
* @param limit - The maximum number of foods to retrieve.
* @param offset - The offset to start retrieving foods from.
* @returns A promise that resolves to an object containing the list of foods and any error that occurred.
*/
export const getFoods = async (
userId?: string,
isShared?: boolean,
search?: string,
limit?: number,
offset?: number
): Promise<{ data: Foods[] | null; error: PostgrestError | null }> => {
let query = supabase
.from("foods")
.select(
`id, name, description, time_to_cook_minutes, skill_level, ingredient_count, calories, image_url, is_shared, created_by, created_at`
);
if (userId) {
query = query.eq("created_by", userId);
}
if (typeof isShared === "boolean") {
query = query.eq("is_shared", isShared);
}
if (search) {
query = query.or(`name.ilike.%${search}%,description.ilike.%${search}%`);
}
if (typeof limit === "number") {
query = query.limit(limit);
}
if (typeof offset === "number") {
query = query.range(offset, offset + (limit ? limit - 1 : 9));
}
const { data, error } = await query;
return { data, error };
};