From 53c5b7b049342e728852a56297c6e557fbd45ddc Mon Sep 17 00:00:00 2001 From: Sosokker Date: Sat, 10 May 2025 04:17:57 +0700 Subject: [PATCH] feat: add avatar image change --- app/(tabs)/profile.tsx | 203 ++++++++++++++++++++++++++++++++------- package-lock.json | 11 +++ package.json | 1 + services/data/profile.ts | 34 ++++--- 4 files changed, 205 insertions(+), 44 deletions(-) diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index b080de9..bc05f4d 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,27 +1,32 @@ "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 { ActivityIndicator, Image, + Modal, + Pressable, ScrollView, Text, + TextInput, TouchableOpacity, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; - -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"; +import uuid from "react-native-uuid"; export default function ProfileScreen() { const [activeTab, setActiveTab] = useState("My Recipes"); const { isAuthenticated } = useAuth(); const isFocused = useIsFocused(); + const queryClient = useQueryClient(); const { data: userData, @@ -35,7 +40,7 @@ export default function ProfileScreen() { return data?.user; }, enabled: isAuthenticated, - subscribed: isFocused, + staleTime: 0, }); const userId = userData?.id; @@ -50,6 +55,7 @@ export default function ProfileScreen() { return getProfile(userId); }, enabled: !!userId, + staleTime: 0, subscribed: isFocused, }); @@ -64,9 +70,80 @@ export default function ProfileScreen() { return getFoods(userId); }, 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(null); + const [editLoading, setEditLoading] = useState(false); + const [editError, setEditError] = useState(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 => { + 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) { return ( @@ -88,21 +165,21 @@ export default function ProfileScreen() { return ( - {/* Profile Header */} - - - 👨‍🍳 - + + {isLoading ? ( - + ) : error ? ( - + {error.message || error.toString()} ) : ( @@ -110,9 +187,79 @@ export default function ProfileScreen() { {profileData?.data?.username ?? "-"} )} - + { + setEditUsername(profileData?.data?.username ?? ""); + setEditImage(profileData?.data?.avatar_url ?? null); + setEditError(null); + setModalVisible(true); + }} + > Edit + + {/* Edit Modal */} + setModalVisible(false)} + > + + + + Edit Profile + + + + + Change Photo + + + Username + + {editError && ( + + {editError} + + )} + + + setModalVisible(false)} + disabled={editLoading} + > + Cancel + + + {editLoading ? ( + + ) : ( + Save + )} + + + + + {/* Tab Navigation */} @@ -132,7 +279,7 @@ export default function ProfileScreen() { - {/* Food Grid / Tab Content */} + {/* Recipes */} {activeTab === "My Recipes" && ( {isFoodsLoading ? ( @@ -146,7 +293,7 @@ export default function ProfileScreen() { {foodsError.message || foodsError.toString()} ) : foodsData?.data?.length ? ( - foodsData.data.map((item, index) => ( + foodsData.data.map((item) => ( )} - {activeTab === "Likes" && ( - - Liked recipes will appear here. - - )} - {activeTab === "Saved" && ( - - Saved recipes will appear here. - - )} ); diff --git a/package-lock.json b/package-lock.json index f1f18be..8a6bc5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "react-native-reanimated": "^3.16.2", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "~4.10.0", + "react-native-uuid": "^2.0.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", "tailwindcss": "^3.4.17" @@ -10889,6 +10890,16 @@ "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": { "version": "0.20.0", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz", diff --git a/package.json b/package.json index 5e86abc..dee44a8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-native-reanimated": "^3.16.2", "react-native-safe-area-context": "^5.4.0", "react-native-screens": "~4.10.0", + "react-native-uuid": "^2.0.3", "react-native-web": "~0.20.0", "react-native-webview": "13.13.5", "tailwindcss": "^3.4.17" diff --git a/services/data/profile.ts b/services/data/profile.ts index 4a3dc75..066c7be 100644 --- a/services/data/profile.ts +++ b/services/data/profile.ts @@ -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 }> { - const { data, error } = await supabase - .from('profiles') - .update({ username: username }) - .eq('id', userId) - .select() - .single() - - return { data, error } -} \ No newline at end of file +export async function updateProfile( + userId: string, + username?: string | null, + avatar_url?: string | null +): Promise<{ data: any; error: PostgrestError | null }> { + const updateData: Record = {} + if (username !== undefined && username !== null) { + updateData.username = username + } + 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 } +}