feat: add avatar image change

This commit is contained in:
Sosokker 2025-05-10 04:17:57 +07:00
parent f1e22594b9
commit 53c5b7b049
4 changed files with 205 additions and 44 deletions

View File

@ -1,27 +1,32 @@
"use client"; "use client";
import { useAuth } from "@/context/auth-context";
import { getFoods } from "@/services/data/foods";
import { getProfile, updateProfile } from "@/services/data/profile";
import { supabase } from "@/services/supabase";
import { useIsFocused } from "@react-navigation/native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as ImagePicker from "expo-image-picker";
import { useState } from "react"; import { useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Image, Image,
Modal,
Pressable,
ScrollView, ScrollView,
Text, Text,
TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import uuid from "react-native-uuid";
import { useAuth } from "@/context/auth-context";
import { getFoods } from "@/services/data/foods";
import { getProfile } from "@/services/data/profile";
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("My Recipes"); const [activeTab, setActiveTab] = useState("My Recipes");
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const queryClient = useQueryClient();
const { const {
data: userData, data: userData,
@ -35,7 +40,7 @@ export default function ProfileScreen() {
return data?.user; return data?.user;
}, },
enabled: isAuthenticated, enabled: isAuthenticated,
subscribed: isFocused, staleTime: 0,
}); });
const userId = userData?.id; const userId = userData?.id;
@ -50,6 +55,7 @@ export default function ProfileScreen() {
return getProfile(userId); return getProfile(userId);
}, },
enabled: !!userId, enabled: !!userId,
staleTime: 0,
subscribed: isFocused, subscribed: isFocused,
}); });
@ -64,9 +70,80 @@ export default function ProfileScreen() {
return getFoods(userId); return getFoods(userId);
}, },
enabled: !!userId && activeTab === "My Recipes", enabled: !!userId && activeTab === "My Recipes",
subscribed: isFocused && activeTab === "My Recipes", staleTime: 0,
}); });
const [modalVisible, setModalVisible] = useState(false);
const [editUsername, setEditUsername] = useState("");
const [editImage, setEditImage] = useState<string | null>(null);
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
setEditError("Permission to access media library is required.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.7,
allowsEditing: true,
});
if (!result.canceled) {
setEditImage(result.assets[0].uri);
}
};
const uploadImageToSupabase = async (uri: string): Promise<string> => {
const fileName = `${userId}/${uuid.v4()}.jpg`;
const response = await fetch(uri);
const blob = await response.blob();
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(fileName, blob, {
contentType: "image/jpeg",
upsert: true,
});
if (uploadError) throw uploadError;
const { data } = supabase.storage.from("avatars").getPublicUrl(fileName);
return data.publicUrl;
};
const handleSaveProfile = async () => {
setEditLoading(true);
setEditError(null);
try {
if (!editUsername.trim()) throw new Error("Username cannot be empty");
let avatarUrl = profileData?.data?.avatar_url ?? null;
if (editImage && editImage !== avatarUrl) {
avatarUrl = await uploadImageToSupabase(editImage);
}
const { error: updateError } = await updateProfile(
userId!,
editUsername.trim(),
avatarUrl
);
if (updateError) throw updateError;
setModalVisible(false);
await queryClient.invalidateQueries({ queryKey: ["profile", userId] });
} catch (err: any) {
setEditError(err.message || "Failed to update profile");
} finally {
setEditLoading(false);
}
};
if (isUserLoading) { if (isUserLoading) {
return ( return (
<SafeAreaView className="flex-1 justify-center items-center bg-white"> <SafeAreaView className="flex-1 justify-center items-center bg-white">
@ -88,21 +165,21 @@ export default function ProfileScreen() {
return ( return (
<SafeAreaView className="flex-1 bg-white" edges={["top"]}> <SafeAreaView className="flex-1 bg-white" edges={["top"]}>
<ScrollView className="flex-1"> <ScrollView className="flex-1">
{/* Profile Header */}
<View className="items-center py-6"> <View className="items-center py-6">
<View className="w-[100px] h-[100px] rounded-full border border-gray-300 justify-center items-center mb-3"> <View className="w-[100px] h-[100px] rounded-full border border-gray-300 justify-center items-center mb-3 overflow-hidden">
<View className="w-[96px] h-[96px] rounded-full bg-gray-100 justify-center items-center"> <Image
<Text className="text-5xl">👨🍳</Text> source={
</View> profileData?.data?.avatar_url
? { uri: profileData.data.avatar_url }
: require("@/assets/images/placeholder-food.jpg")
}
className="w-[96px] h-[96px] rounded-full"
/>
</View> </View>
{isLoading ? ( {isLoading ? (
<ActivityIndicator <ActivityIndicator size="small" color="#bb0718" />
size="small"
color="#bb0718"
style={{ marginBottom: 12 }}
/>
) : error ? ( ) : error ? (
<Text className="text-xl font-bold mb-3 text-red-600"> <Text className="text-red-600 font-bold mb-3">
{error.message || error.toString()} {error.message || error.toString()}
</Text> </Text>
) : ( ) : (
@ -110,9 +187,79 @@ export default function ProfileScreen() {
{profileData?.data?.username ?? "-"} {profileData?.data?.username ?? "-"}
</Text> </Text>
)} )}
<TouchableOpacity className="bg-red-600 py-2 px-10 rounded-lg"> <TouchableOpacity
className="bg-red-600 py-2 px-10 rounded-lg"
onPress={() => {
setEditUsername(profileData?.data?.username ?? "");
setEditImage(profileData?.data?.avatar_url ?? null);
setEditError(null);
setModalVisible(true);
}}
>
<Text className="text-white font-bold">Edit</Text> <Text className="text-white font-bold">Edit</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Edit Modal */}
<Modal
visible={modalVisible}
animationType="slide"
transparent
onRequestClose={() => setModalVisible(false)}
>
<View className="flex-1 justify-center items-center bg-black bg-opacity-40">
<View className="bg-white rounded-xl p-6 w-11/12 max-w-md shadow-lg">
<Text className="text-lg font-bold mb-4 text-center">
Edit Profile
</Text>
<Pressable className="items-center mb-4" onPress={pickImage}>
<Image
source={
editImage
? { uri: editImage }
: require("@/assets/images/placeholder-food.jpg")
}
className="w-24 h-24 rounded-full mb-2 bg-gray-200"
/>
<Text className="text-blue-600 underline">Change Photo</Text>
</Pressable>
<Text className="mb-1 font-medium">Username</Text>
<TextInput
className="border border-gray-300 rounded px-3 py-2 mb-4"
value={editUsername}
onChangeText={setEditUsername}
placeholder="Enter new username"
/>
{editError && (
<Text className="text-red-600 mb-2 text-center">
{editError}
</Text>
)}
<View className="flex-row justify-between mt-2">
<TouchableOpacity
className="bg-gray-300 py-2 px-6 rounded-lg"
onPress={() => setModalVisible(false)}
disabled={editLoading}
>
<Text className="text-gray-700 font-bold">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className="bg-red-600 py-2 px-6 rounded-lg"
onPress={handleSaveProfile}
disabled={editLoading}
>
{editLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-white font-bold">Save</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View> </View>
{/* Tab Navigation */} {/* Tab Navigation */}
@ -132,7 +279,7 @@ export default function ProfileScreen() {
<View className="h-px bg-[#EEEEEE] mx-4" /> <View className="h-px bg-[#EEEEEE] mx-4" />
{/* Food Grid / Tab Content */} {/* Recipes */}
{activeTab === "My Recipes" && ( {activeTab === "My Recipes" && (
<View className="flex-row flex-wrap p-2"> <View className="flex-row flex-wrap p-2">
{isFoodsLoading ? ( {isFoodsLoading ? (
@ -146,7 +293,7 @@ export default function ProfileScreen() {
{foodsError.message || foodsError.toString()} {foodsError.message || foodsError.toString()}
</Text> </Text>
) : foodsData?.data?.length ? ( ) : foodsData?.data?.length ? (
foodsData.data.map((item, index) => ( foodsData.data.map((item) => (
<View key={item.id} className="w-1/2 p-2 relative"> <View key={item.id} className="w-1/2 p-2 relative">
<Image <Image
source={ source={
@ -170,16 +317,6 @@ export default function ProfileScreen() {
)} )}
</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>
); );

11
package-lock.json generated
View File

@ -39,6 +39,7 @@
"react-native-reanimated": "^3.16.2", "react-native-reanimated": "^3.16.2",
"react-native-safe-area-context": "^5.4.0", "react-native-safe-area-context": "^5.4.0",
"react-native-screens": "~4.10.0", "react-native-screens": "~4.10.0",
"react-native-uuid": "^2.0.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"
@ -10889,6 +10890,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-uuid": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/react-native-uuid/-/react-native-uuid-2.0.3.tgz",
"integrity": "sha512-f/YfIS2f5UB+gut7t/9BKGSCYbRA9/74A5R1MDp+FLYsuS+OSWoiM/D8Jko6OJB6Jcu3v6ONuddvZKHdIGpeiw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/react-native-web": { "node_modules/react-native-web": {
"version": "0.20.0", "version": "0.20.0",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",

View File

@ -42,6 +42,7 @@
"react-native-reanimated": "^3.16.2", "react-native-reanimated": "^3.16.2",
"react-native-safe-area-context": "^5.4.0", "react-native-safe-area-context": "^5.4.0",
"react-native-screens": "~4.10.0", "react-native-screens": "~4.10.0",
"react-native-uuid": "^2.0.3",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"

View File

@ -28,15 +28,27 @@ export async function getProfile(userId: string): Promise<{
} }
/** /**
* Updates the username of a user in the `profiles` table. * Updates the username (and optionally avatar_url) of a user in the `profiles` table.
*/ */
export async function updateProfile(userId: string, username: string): Promise<{ data: any; error: PostgrestError | null }> { export async function updateProfile(
const { data, error } = await supabase userId: string,
.from('profiles') username?: string | null,
.update({ username: username }) avatar_url?: string | null
.eq('id', userId) ): Promise<{ data: any; error: PostgrestError | null }> {
.select() const updateData: Record<string, string | null> = {}
.single() if (username !== undefined && username !== null) {
updateData.username = username
return { data, error } }
} if (avatar_url !== undefined && avatar_url !== null) {
updateData.avatar_url = avatar_url
}
const { data, error } = await supabase
.from('profiles')
.update(updateData)
.eq('id', userId)
.select()
.single()
return { data, error }
}