From 304a937417d63f97bcdb1dc481548236aaff5704 Mon Sep 17 00:00:00 2001 From: donggyu412 Date: Sun, 14 Dec 2025 17:49:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=B9=84=EC=84=A0=ED=98=B8=20=EC=8B=9D?= =?UTF-8?q?=EC=9E=AC=EB=A3=8Capi=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/DislikedFoodsModal.tsx | 307 ++++++++-------- src/screens/diet/MealRecommendScreen.tsx | 347 +++---------------- src/screens/diet/TempMealRecommendScreen.tsx | 156 +++++---- src/services/userPreferencesAPI.ts | 128 ++++--- src/types/index.ts | 13 +- 5 files changed, 371 insertions(+), 580 deletions(-) diff --git a/src/components/modals/DislikedFoodsModal.tsx b/src/components/modals/DislikedFoodsModal.tsx index 127f8df..6e29994 100644 --- a/src/components/modals/DislikedFoodsModal.tsx +++ b/src/components/modals/DislikedFoodsModal.tsx @@ -11,61 +11,60 @@ import { Alert, ActivityIndicator, Dimensions, + Keyboard, } from "react-native"; import { Ionicons as Icon } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { userPreferencesAPI } from "../../services/userPreferencesAPI"; +import type { ExclusionResponse } from "../../types"; const { width } = Dimensions.get("window"); interface DislikedFoodsModalProps { visible: boolean; onClose: () => void; - onSave?: (foods: string[]) => void; + userId: string; // [필수] API 호출을 위한 유저 ID (예: "ehdrb") + onUpdate?: () => void; // 데이터 변경 시 부모에게 알림 (선택 사항) } const DislikedFoodsModal = ({ visible, onClose, - onSave, + userId, + onUpdate, }: DislikedFoodsModalProps) => { - const [dislikedFoods, setDislikedFoods] = useState([]); - const [originalFoods, setOriginalFoods] = useState([]); + // 데이터 구조 변경: 단순 string[] -> { id, food_name, reason }[] + const [dislikedFoods, setDislikedFoods] = useState([]); const [inputValue, setInputValue] = useState(""); - const [loading, setLoading] = useState(false); - const [hasChanges, setHasChanges] = useState(false); - // 모달이 열릴 때 데이터 로드 + // 로딩 상태 분리 (목록 로드 vs 작업 처리) + const [initialLoading, setInitialLoading] = useState(false); + const [processing, setProcessing] = useState(false); + + // 모달 열릴 때 목록 새로고침 useEffect(() => { - if (visible) { + if (visible && userId) { loadDislikedFoods(); } - }, [visible]); - - // 변경사항 감지 - useEffect(() => { - const changed = - JSON.stringify(dislikedFoods.sort()) !== - JSON.stringify(originalFoods.sort()); - setHasChanges(changed); - }, [dislikedFoods, originalFoods]); + }, [visible, userId]); const loadDislikedFoods = async () => { try { - setLoading(true); - const foods = await userPreferencesAPI.getDislikedFoods(); + setInitialLoading(true); + const foods = await userPreferencesAPI.getExclusions(userId); setDislikedFoods(foods); - setOriginalFoods(foods); - setHasChanges(false); } catch (error: any) { console.error("Failed to load disliked foods", error); Alert.alert("오류", "금지 식단을 불러오는데 실패했습니다."); } finally { - setLoading(false); + setInitialLoading(false); } }; - const handleAdd = () => { + /** + * 음식 추가 핸들러 (API 즉시 호출) + */ + const handleAdd = async () => { const trimmed = inputValue.trim(); if (!trimmed) { @@ -73,73 +72,66 @@ const DislikedFoodsModal = ({ return; } - if (dislikedFoods.includes(trimmed)) { - Alert.alert("알림", "이미 추가된 음식입니다."); + // 중복 검사 (이름 기준) + const isDuplicate = dislikedFoods.some( + (item) => item.food_name === trimmed + ); + if (isDuplicate) { + Alert.alert("알림", "이미 목록에 있는 음식입니다."); return; } - setDislikedFoods((prev) => [...prev, trimmed]); - setInputValue(""); - }; - - const handleRemove = (food: string) => { - Alert.alert("삭제", `"${food}"를 삭제하시겠습니까?`, [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: () => { - setDislikedFoods((prev) => prev.filter((f) => f !== food)); - }, - }, - ]); - }; - - const handleSave = async () => { try { - setLoading(true); - - await userPreferencesAPI.saveDislikedFoods(dislikedFoods); + setProcessing(true); + Keyboard.dismiss(); - Alert.alert("성공", "금지 식단이 저장되었습니다."); - setOriginalFoods(dislikedFoods); - setHasChanges(false); + // [API] 추가 요청 + const newFood = await userPreferencesAPI.addExclusions(userId, [trimmed]); - if (onSave) { - onSave(dislikedFoods); - } + // 목록 갱신 + setDislikedFoods((prev) => [...prev, newFood]); + setInputValue(""); - onClose(); + if (onUpdate) onUpdate(); } catch (error: any) { - console.error("Failed to save disliked foods", error); - Alert.alert("오류", error.message || "저장에 실패했습니다."); + console.error("Add failed", error); + Alert.alert("오류", "음식을 추가하지 못했습니다."); } finally { - setLoading(false); + setProcessing(false); } }; - const handleClose = () => { - if (hasChanges) { - Alert.alert( - "변경사항", - "저장하지 않은 변경사항이 있습니다. 나가시겠습니까?", - [ - { text: "취소", style: "cancel" }, - { - text: "나가기", - style: "destructive", - onPress: () => { - setDislikedFoods(originalFoods); - setInputValue(""); - setHasChanges(false); - onClose(); - }, - }, - ] - ); - } else { - onClose(); - } + /** + * 음식 삭제 핸들러 (API 즉시 호출) + * @param id 삭제할 항목의 고유 ID + * @param name 표시용 음식 이름 + */ + const handleRemove = (id: number, name: string) => { + Alert.alert("삭제", `"${name}"을(를) 제외 목록에서 삭제하시겠습니까?`, [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setProcessing(true); + + // [API] 삭제 요청 (ID 사용) + await userPreferencesAPI.deleteExclusion(id); + + // 로컬 상태 업데이트 + setDislikedFoods((prev) => prev.filter((item) => item.id !== id)); + + if (onUpdate) onUpdate(); + } catch (error: any) { + console.error("Delete failed", error); + Alert.alert("오류", "삭제에 실패했습니다."); + } finally { + setProcessing(false); + } + }, + }, + ]); }; return ( @@ -147,7 +139,7 @@ const DislikedFoodsModal = ({ visible={visible} transparent={true} animationType="slide" - onRequestClose={handleClose} + onRequestClose={onClose} > {/* 헤더 */} - + 금지 식단 설정 @@ -169,14 +161,11 @@ const DislikedFoodsModal = ({ - 알레르기나 선호하지 않는 음식을 추가하면 식단 추천에서 제외됩니다. + 추가된 음식은 즉시 서버에 저장되며 식단 추천에서 제외됩니다. - + {/* 입력 필드 */} 음식 추가 @@ -193,103 +182,117 @@ const DislikedFoodsModal = ({ /> - + {processing ? ( + + ) : ( + + )} {/* 금지 식단 목록 */} - + 금지 식단 목록 ({dislikedFoods.length}) - {dislikedFoods.length === 0 ? ( - - - - 아직 추가된 금지 식단이 없습니다. - - - 위에서 음식을 추가해보세요. - + {initialLoading ? ( + + ) : ( - - {dislikedFoods.map((food, index) => ( - - - - - - - {food} + + {dislikedFoods.length === 0 ? ( + + + + 아직 추가된 금지 식단이 없습니다. + + + ) : ( + + {dislikedFoods.map((item) => ( + + + + + + + {/* item.food_name 사용 */} + + {item.food_name} + + + + + handleRemove(item.id, item.food_name) + } + style={styles.removeBtn} + disabled={processing} + > + + + - handleRemove(food)} - style={styles.removeBtn} - > - - - + ))} - ))} - + )} + {/* 하단 여백 */} + + )} - + - {/* 저장 버튼 */} + {/* 닫기 버튼 (이제 '저장'이 아니라 단순 '닫기' 역할) */} - + - {loading ? ( - - ) : ( - <> - - - {hasChanges ? "저장하기" : "저장됨"} - - - )} + 닫기 @@ -349,7 +352,7 @@ const styles = StyleSheet.create({ paddingTop: 20, }, inputSection: { - marginBottom: 32, + marginBottom: 24, }, sectionTitle: { fontSize: 16, @@ -394,8 +397,11 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, - listSection: { - marginBottom: 100, + centerState: { + flex: 1, + justifyContent: "center", + alignItems: "center", + marginTop: 40, }, emptyState: { alignItems: "center", @@ -409,10 +415,8 @@ const styles = StyleSheet.create({ marginTop: 16, marginBottom: 8, }, - emptySubtext: { - fontSize: 14, - color: "#666666", - textAlign: "center", + listContent: { + paddingBottom: 20, }, foodList: { gap: 12, @@ -468,9 +472,6 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: "hidden", }, - saveBtnDisabled: { - opacity: 0.5, - }, saveBtnGradient: { flexDirection: "row", justifyContent: "center", diff --git a/src/screens/diet/MealRecommendScreen.tsx b/src/screens/diet/MealRecommendScreen.tsx index 0e83c86..9a271d3 100644 --- a/src/screens/diet/MealRecommendScreen.tsx +++ b/src/screens/diet/MealRecommendScreen.tsx @@ -6,7 +6,6 @@ import { StyleSheet, ScrollView, TouchableOpacity, - TextInput, Alert, ActivityIndicator, Modal, @@ -21,6 +20,7 @@ import { useNavigation } from "@react-navigation/native"; import { recommendedMealAPI, userPreferencesAPI } from "../../services"; import { LinearGradient } from "expo-linear-gradient"; import DislikedFoodsModal from "../../components/modals/DislikedFoodsModal"; +import type { ExclusionResponse } from "../../types"; const { width } = Dimensions.get("window"); @@ -32,6 +32,7 @@ const LOADING_MESSAGES = [ "거의 다 됐어요! 조금만 기다려주세요...", ]; +// ✅ 로딩 오버레이 컴포넌트 const LoadingOverlay = ({ visible, messages = LOADING_MESSAGES, @@ -460,7 +461,7 @@ const mealsModalStyles = StyleSheet.create({ }); const transformTempMealToUI = (tempDay: any, dayIndex: number) => { - console.log(`🔄 ${dayIndex}일차 변환 시작`); + // console.log(`🔄 ${dayIndex}일차 변환 시작`); // 로그 줄임 let breakfast, lunch, dinner; @@ -470,8 +471,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { dinner = tempDay.meals.find((m: any) => m.mealType === "DINNER"); if (!breakfast && !lunch && !dinner) { - console.log(`⚠️ ${dayIndex}일차는 SNACK만 있음 - 변환 시작`); - + // console.log(`⚠️ ${dayIndex}일차는 SNACK만 있음 - 변환 시작`); const snacks = tempDay.meals.filter((m: any) => m.mealType === "SNACK"); if (snacks.length >= 1) { @@ -495,10 +495,6 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { mealTypeName: "저녁", }; } - - console.log( - `✅ SNACK 변환 완료: 아침=${!!breakfast}, 점심=${!!lunch}, 저녁=${!!dinner}` - ); } } @@ -592,13 +588,19 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { const MealRecommendScreen = () => { const navigation = useNavigation(); + const userId = "ehdrb"; + const [screen, setScreen] = useState<"welcome" | "meals">("welcome"); const [showDislikedModal, setShowDislikedModal] = useState(false); const [weeklyMeals, setWeeklyMeals] = useState([]); const [currentDay, setCurrentDay] = useState(0); - const [excludedIngredients, setExcludedIngredients] = useState([]); - const [newIngredient, setNewIngredient] = useState(""); + + // 🔹 [수정] string[] -> ExclusionResponse[] (객체 배열) + const [excludedIngredients, setExcludedIngredients] = useState< + ExclusionResponse[] + >([]); + const [loading, setLoading] = useState(false); const [savedMeals, setSavedMeals] = useState([]); const [currentPlanId, setCurrentPlanId] = useState(null); @@ -617,35 +619,29 @@ const MealRecommendScreen = () => { }).start(); }, [screen]); + // 🔹 [추가] 비선호 식단 목록 로드 함수 + const loadExclusions = async () => { + try { + const data = await userPreferencesAPI.getExclusions(userId); + setExcludedIngredients(data); + console.log("✅ 비선호 음식 로드 완료:", data.length, "개"); + } catch (error) { + console.error("비선호 음식 로드 실패:", error); + } + }; + useEffect(() => { const loadData = async () => { try { - console.log("========== GET 테스트 시작 =========="); - try { - const result = await userPreferencesAPI.getUserPreferences(); - console.log("✅ GET 성공:", result); - console.log("✅ 비선호 음식:", result.dislikedFoods); - } catch (testError) { - console.error("❌ GET 실패:", testError); - } - console.log("========== GET 테스트 완료 =========="); - - const dislikedFoods = await userPreferencesAPI.getDislikedFoods(); - setExcludedIngredients(dislikedFoods); + console.log("========== 초기 데이터 로드 =========="); - console.log("✅ 비선호 음식 로드 완료:", dislikedFoods.length, "개"); + // 비선호 식단 불러오기 + await loadExclusions(); + // 저장된 식단 불러오기 await loadSavedMeals(); } catch (error) { console.error("데이터 로드 실패:", error); - try { - const stored = await AsyncStorage.getItem("excludedIngredients"); - if (stored) { - setExcludedIngredients(JSON.parse(stored)); - } - } catch (e) { - console.error("로컬 스토리지 읽기 실패:", e); - } } }; loadData(); @@ -658,8 +654,6 @@ const MealRecommendScreen = () => { const serverPlans = await recommendedMealAPI.getSavedMealPlans(); - console.log("📦 서버에서 받은 plans:", serverPlans.length); - const bundleMap = new Map(); serverPlans.forEach((plan) => { @@ -689,16 +683,7 @@ const MealRecommendScreen = () => { description: `${bundle.mealCount}일 식단`, })); - console.log("✅ 그룹화된 서버 번들:", serverBundles.length); - const allMeals = [...localMeals, ...serverBundles]; - - console.log("📋 저장된 식단:", { - 로컬: localMeals.length, - 서버: serverBundles.length, - 합계: allMeals.length, - }); - setSavedMeals(allMeals); } catch (error) { console.error("저장된 식단 불러오기 실패:", error); @@ -725,29 +710,8 @@ const MealRecommendScreen = () => { try { console.log("🍽️ 임시 식단 생성 시작"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); - console.log("=== 📦 API 응답 원본 ==="); - console.log("응답 배열 길이:", tempMeals.length); - console.log("전체 응답:", JSON.stringify(tempMeals, null, 2)); - - tempMeals.forEach((day, index) => { - console.log(`\n=== ${index + 1}일차 상세 ===`); - console.log("dayIndex:", day.dayIndex); - console.log("meals 배열:", day.meals); - console.log("meals 길이:", day.meals?.length || 0); - - day.meals?.forEach((meal, mealIdx) => { - console.log(` - ${meal.mealType}:`, { - id: meal.id, - foods개수: meal.foods?.length || 0, - totalCalories: meal.totalCalories, - }); - }); - }); - if (!tempMeals || tempMeals.length === 0) { throw new Error("식단 생성에 실패했습니다."); } @@ -756,27 +720,9 @@ const MealRecommendScreen = () => { const weekData = tempMeals.map((tempDay, index) => { const transformed = transformTempMealToUI(tempDay, index + 1); - - console.log(`\n=== ${index + 1}일차 변환 후 ===`); - console.log("totalCalories:", transformed.totalCalories); - console.log("아침 음식 수:", transformed.breakfast.meals.length); - console.log("점심 음식 수:", transformed.lunch.meals.length); - console.log("저녁 음식 수:", transformed.dinner.meals.length); - return transformed; }); - console.log("\n=== 📊 최종 weekData ==="); - console.log("weekData 길이:", weekData.length); - weekData.forEach((day, idx) => { - console.log(`${idx + 1}일차:`, { - totalCalories: day.totalCalories, - 아침: day.breakfast.meals.length, - 점심: day.lunch.meals.length, - 저녁: day.dinner.meals.length, - }); - }); - setWeeklyMeals(weekData); setCurrentPlanId(null); setScreen("meals"); @@ -800,8 +746,6 @@ const MealRecommendScreen = () => { try { console.log("🍽️ 1일 식단 생성 시작"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - const tempMeals = await recommendedMealAPI.getDailyMealPlan(mealsPerDay); if (!tempMeals || tempMeals.length === 0) { @@ -822,14 +766,11 @@ const MealRecommendScreen = () => { Alert.alert("성공", "오늘의 맞춤 식단이 생성되었습니다! 🎉"); } catch (error: any) { console.error("❌ 1일 식단 추천 실패:", error); - - // 500 에러인 경우 더 친절한 메시지 let errorMessage = error.message || "식단을 불러오는데 실패했습니다."; if (error.status === 500) { errorMessage = "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; } - Alert.alert("오류", errorMessage); } finally { setLoading(false); @@ -854,20 +795,6 @@ const MealRecommendScreen = () => { }; updated[currentDay] = dayMeals; - - console.log( - `${mealArray[mealIndex].liked ? "💚" : "🤍"} ${ - mealArray[mealIndex].name - } 좋아요 ${mealArray[mealIndex].liked ? "활성화" : "비활성화"}` - ); - - // TODO: 나중에 API 연결 - // if (mealArray[mealIndex].liked) { - // await userPreferencesAPI.addLikedFood(mealArray[mealIndex].name); - // } else { - // await userPreferencesAPI.removeLikedFood(mealArray[mealIndex].name); - // } - return updated; }); }; @@ -955,62 +882,6 @@ const MealRecommendScreen = () => { }); }; - const handleSaveMealPlanLocally = async () => { - try { - setLoading(true); - - const mealsForHistory = weeklyMeals.map((d) => ({ - totalCalories: d.totalCalories, - carbs: d.carbs, - protein: d.protein, - fat: d.fat, - breakfast: d.breakfast, - lunch: d.lunch, - dinner: d.dinner, - })); - - const mealPlanToSave = { - id: `local_${Date.now()}`, - date: new Date().toLocaleDateString("ko-KR"), - planName: weeklyMeals[0]?.planName || "AI 식단", - description: weeklyMeals[0]?.description || "AI가 생성한 식단", - totalCalories: weeklyMeals[0]?.totalCalories || 0, - totalCarbs: weeklyMeals[0]?.carbs || 0, - totalProtein: weeklyMeals[0]?.protein || 0, - totalFat: weeklyMeals[0]?.fat || 0, - createdAt: new Date().toISOString(), - meals: mealsForHistory, - isLocalMeal: true, - }; - - const stored = await AsyncStorage.getItem("savedMealPlans"); - const existingMeals = stored ? JSON.parse(stored) : []; - - const updatedMeals = [mealPlanToSave, ...existingMeals].slice(0, 20); - await AsyncStorage.setItem( - "savedMealPlans", - JSON.stringify(updatedMeals) - ); - - console.log("💾 로컬 저장 완료:", mealPlanToSave.id); - - Alert.alert("저장 완료", "식단이 기기에 저장되었습니다! 🎉", [ - { - text: "확인", - onPress: async () => { - await loadSavedMeals(); - setScreen("welcome"); - }, - }, - ]); - } catch (error: any) { - console.error("로컬 저장 실패:", error); - Alert.alert("오류", "식단 저장에 실패했습니다."); - } finally { - setLoading(false); - } - }; - const handleSaveMealPlan = async () => { try { setLoading(true); @@ -1062,10 +933,7 @@ const MealRecommendScreen = () => { "savedMealPlans", JSON.stringify(updatedMeals) ); - - console.log("🗑️ 로컬 식단 삭제:", meal.id); } else { - console.log("🗑️ 서버 번들 삭제:", meal.bundleId || meal.id); await recommendedMealAPI.deleteBundle(meal.bundleId || meal.id); } @@ -1100,6 +968,14 @@ const MealRecommendScreen = () => { onCancel={handleCancelLoading} /> + {/* 🔹 [수정] 모달 연결: userId 및 onUpdate 전달 */} + setShowDislikedModal(false)} + onUpdate={loadExclusions} + /> + {/* ✅ 끼니 선택 모달 */} { {/* 3. 금지 식재료 관리 */} setShowDislikedModal(true)} // 변경 + onPress={() => setShowDislikedModal(true)} activeOpacity={0.8} > { + {/* 🔹 [수정] 제외된 식재료 미리보기 (객체 구조 반영) */} {excludedIngredients.length > 0 && ( 제외된 식재료 - {excludedIngredients.map((ingredient, index) => ( - + {excludedIngredients.map((item) => ( + { ]} style={styles.tagGradient} > - {ingredient} + {/* item.food_name 사용 */} + {item.food_name} ))} @@ -1303,6 +1181,7 @@ const MealRecommendScreen = () => { setScreen("meals"); setCurrentDay(0); } else { + // TODO: 서버 식단 상세 보기 구현 시 연결 navigation.navigate("MealRecommendHistory" as never); } }} @@ -2268,148 +2147,6 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, - excludedForm: { - flex: 1, - }, - excludedFormContent: { - paddingHorizontal: 20, - paddingTop: 30, - paddingBottom: 40, - }, - inputGroup: { - flexDirection: "row", - gap: 12, - marginBottom: 30, - }, - inputWrapper: { - flex: 1, - borderRadius: 16, - overflow: "hidden", - }, - inputGradient: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 16, - paddingVertical: 4, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 16, - }, - inputIcon: { - marginRight: 12, - }, - textInput: { - flex: 1, - height: 52, - color: "#ffffff", - fontSize: 15, - letterSpacing: 0.3, - }, - addButton: { - width: 56, - height: 56, - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 6, - }, - addButtonGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - excludedList: { - gap: 12, - marginBottom: 30, - minHeight: 200, - }, - excludedItem: { - borderRadius: 14, - overflow: "hidden", - }, - excludedItemGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: 16, - paddingHorizontal: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 14, - }, - excludedItemLeft: { - flexDirection: "row", - alignItems: "center", - flex: 1, - gap: 12, - }, - excludedItemIcon: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: "rgba(239,68,68,0.1)", - alignItems: "center", - justifyContent: "center", - }, - excludedItemText: { - fontSize: 16, - fontWeight: "500", - color: "#ffffff", - flex: 1, - letterSpacing: 0.3, - }, - removeButton: { - width: 32, - height: 32, - alignItems: "center", - justifyContent: "center", - }, - emptyState: { - alignItems: "center", - justifyContent: "center", - paddingVertical: 60, - paddingHorizontal: 40, - }, - emptyMessage: { - textAlign: "center", - color: "#6b7280", - fontSize: 16, - fontWeight: "600", - marginTop: 16, - marginBottom: 8, - letterSpacing: 0.3, - }, - emptySubtext: { - textAlign: "center", - color: "#4b5563", - fontSize: 14, - letterSpacing: 0.2, - }, - completeButton: { - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - completeButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 18, - }, - completeButtonText: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, mealDateContainer: { paddingHorizontal: 20, paddingTop: 16, diff --git a/src/screens/diet/TempMealRecommendScreen.tsx b/src/screens/diet/TempMealRecommendScreen.tsx index e262af4..788d470 100644 --- a/src/screens/diet/TempMealRecommendScreen.tsx +++ b/src/screens/diet/TempMealRecommendScreen.tsx @@ -22,9 +22,14 @@ import { RootStackParamList } from "../../navigation/types"; import { SafeAreaView } from "react-native-safe-area-context"; import { LinearGradient } from "expo-linear-gradient"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { userPreferencesAPI, recommendedMealAPI } from "../../services"; - +// 🔹 authAPI 추가 import +import { + userPreferencesAPI, + recommendedMealAPI, + authAPI, +} from "../../services"; import { TempDayMeal } from "../../services/recommendedMealAPI"; +import type { ExclusionResponse } from "../../types"; type NavigationProp = NativeStackNavigationProp; const { width } = Dimensions.get("window"); @@ -37,6 +42,9 @@ const LOADING_MESSAGES = [ "거의 다 됐어요! 조금만 기다려주세요...", ]; +// ... (LoadingOverlay, MealsSelectionModal, mealsModalStyles, Interface 등은 기존과 동일하므로 생략) ... +// (위쪽 코드는 변경사항이 없으므로 그대로 두시면 됩니다. TempMealRecommendScreen 컴포넌트 내부만 수정합니다.) + const LoadingOverlay = ({ visible, messages = LOADING_MESSAGES, @@ -46,6 +54,7 @@ const LoadingOverlay = ({ messages?: string[]; onCancel?: () => void; }) => { + // ... 기존 코드 유지 const [currentMessageIndex, setCurrentMessageIndex] = useState(0); const fadeAnim = useRef(new Animated.Value(1)).current; const spinAnim = useRef(new Animated.Value(0)).current; @@ -176,7 +185,6 @@ const LoadingOverlay = ({ ); }; -// ✅ 끼니 선택 모달 const MealsSelectionModal = ({ visible, currentMeals, @@ -417,11 +425,16 @@ interface FoodItem { const TempMealRecommendScreen: React.FC = () => { const navigation = useNavigation(); + // 🔹 [변경] 하드코딩 제거 후 state로 관리 + const [userId, setUserId] = useState(""); + const [screen, setScreen] = useState< "input" | "excludedIngredients" | "result" >("input"); const [currentDayTab, setCurrentDayTab] = useState(0); - const [excludedFoods, setExcludedFoods] = useState([]); + + const [excludedFoods, setExcludedFoods] = useState([]); + const [newIngredient, setNewIngredient] = useState(""); const [selectedPeriod, setSelectedPeriod] = useState<"daily" | "weekly">( "daily" @@ -429,11 +442,9 @@ const TempMealRecommendScreen: React.FC = () => { const [recommendedMeals, setRecommendedMeals] = useState([]); const [loading, setLoading] = useState(false); - // ✅ 끼니 수 관련 state const [mealsPerDay, setMealsPerDay] = useState(3); const [showMealsModal, setShowMealsModal] = useState(false); - // 토큰 부족 상태 관리 const [isTokenDepleted, setIsTokenDepleted] = useState(false); const [dailyNutrition, setDailyNutrition] = useState({ @@ -453,24 +464,29 @@ const TempMealRecommendScreen: React.FC = () => { }).start(); }, [screen]); + // 🔹 [변경] 데이터 로드 로직 수정: 프로필 조회 후 -> 식단 조회 useEffect(() => { - loadUserData(); + const init = async () => { + await loadUserData(); + }; + init(); }, []); const loadUserData = async () => { try { - const preferences = await userPreferencesAPI.getUserPreferences(); - if (preferences.dislikedFoods) { - setExcludedFoods(preferences.dislikedFoods); - } - console.log( - "✅ 비선호 음식 로드 완료:", - preferences.dislikedFoods?.length || 0, - "개" - ); + // 1. 프로필 조회하여 userId 획득 + const profile = await authAPI.getProfile(); + const currentUserId = profile.userId; + setUserId(currentUserId); + console.log("✅ 유저 ID 로드 완료:", currentUserId); + + // 2. 획득한 userId로 비선호 식단 조회 + const exclusions = await userPreferencesAPI.getExclusions(currentUserId); + setExcludedFoods(exclusions); + console.log("✅ 비선호 음식 로드 완료:", exclusions.length, "개"); } catch (error) { console.error("사용자 데이터 로드 실패:", error); - // 로컬 스토리지에서 불러오기 시도 + // 실패 시 로컬 스토리지 시도 (userId가 없으면 기능 제한될 수 있음) try { const stored = await AsyncStorage.getItem("excludedIngredients"); if (stored) { @@ -583,10 +599,10 @@ const TempMealRecommendScreen: React.FC = () => { ) { setIsTokenDepleted(true); } else { - // 500 에러인 경우 더 친절한 메시지 let finalErrorMessage = errorMessage || "식단 생성에 실패했습니다."; if (error.status === 500) { - finalErrorMessage = "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; + finalErrorMessage = + "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; } Alert.alert("오류", finalErrorMessage); } @@ -645,11 +661,20 @@ const TempMealRecommendScreen: React.FC = () => { const handleAddExcludedIngredient = async () => { const trimmed = newIngredient.trim(); - if (!trimmed) { + if (!trimmed) return; + if (!userId) { + Alert.alert( + "오류", + "유저 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요." + ); return; } - if (excludedFoods.includes(trimmed)) { + const isDuplicate = excludedFoods.some( + (item) => item.food_name === trimmed + ); + + if (isDuplicate) { Alert.alert("알림", "이미 추가된 식재료입니다."); return; } @@ -657,46 +682,19 @@ const TempMealRecommendScreen: React.FC = () => { try { setLoading(true); - try { - const result = await userPreferencesAPI.addDislikedFoods( - excludedFoods, - [trimmed] - ); - - setExcludedFoods(result.updatedList); - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(result.updatedList) - ); - - setNewIngredient(""); - console.log("✅ 비선호 음식 추가 완료 (서버):", result.updatedList); - return; - } catch (serverError: any) { - console.warn("⚠️ 서버 저장 실패, 로컬만 저장:", serverError.message); - - if ( - serverError.message?.includes("서버 내부 오류") || - serverError.status === 500 - ) { - const updatedList = [...excludedFoods, trimmed]; - setExcludedFoods(updatedList); - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(updatedList) - ); - - setNewIngredient(""); - Alert.alert( - "일부 성공", - "식재료가 기기에 저장되었습니다.\n(서버 동기화는 백엔드 수정 후 가능합니다)" - ); - console.log("✅ 비선호 음식 추가 완료 (로컬만):", updatedList); - return; - } + // 🔹 [변경] state로 저장된 userId 사용 + const newItem = await userPreferencesAPI.addExclusions(userId, [trimmed]); - throw serverError; - } + const updatedList = [...excludedFoods, newItem]; + setExcludedFoods(updatedList); + + await AsyncStorage.setItem( + "excludedIngredients", + JSON.stringify(updatedList) + ); + + setNewIngredient(""); + console.log("✅ 비선호 음식 추가 완료:", newItem); } catch (error: any) { console.error("비선호 음식 추가 실패:", error); Alert.alert("오류", error.message || "식재료 추가에 실패했습니다."); @@ -706,8 +704,8 @@ const TempMealRecommendScreen: React.FC = () => { }; // ✅ 금지 식재료 삭제 - const handleRemoveExcludedIngredient = async (ingredient: string) => { - Alert.alert("삭제", `"${ingredient}"를 삭제하시겠습니까?`, [ + const handleRemoveExcludedIngredient = async (id: number, name: string) => { + Alert.alert("삭제", `"${name}"를 삭제하시겠습니까?`, [ { text: "취소", style: "cancel" }, { text: "삭제", @@ -716,19 +714,17 @@ const TempMealRecommendScreen: React.FC = () => { try { setLoading(true); - const result = await userPreferencesAPI.removeDislikedFood( - excludedFoods, - ingredient - ); + await userPreferencesAPI.deleteExclusion(id); - setExcludedFoods(result.updatedList); + const updatedList = excludedFoods.filter((item) => item.id !== id); + setExcludedFoods(updatedList); await AsyncStorage.setItem( "excludedIngredients", - JSON.stringify(result.updatedList) + JSON.stringify(updatedList) ); - console.log("✅ 비선호 음식 삭제 완료:", result.updatedList); + console.log("✅ 비선호 음식 삭제 완료 (ID):", id); } catch (error: any) { console.error("비선호 음식 삭제 실패:", error); Alert.alert("오류", error.message || "식재료 삭제에 실패했습니다."); @@ -740,6 +736,9 @@ const TempMealRecommendScreen: React.FC = () => { ]); }; + // ... (나머지 render 코드는 UI만 그리므로 수정사항 없이 위에서 제공한 코드와 동일합니다. + // styles나 loadingStyles도 동일합니다.) + // ✅ input 화면 (웰컴 화면) if (screen === "input") { return ( @@ -756,7 +755,6 @@ const TempMealRecommendScreen: React.FC = () => { onCancel={handleCancelLoading} /> - {/* ✅ 끼니 선택 모달 */} { 제외된 식재료 - {excludedFoods.map((ingredient, index) => ( - + {excludedFoods.map((item) => ( + { ]} style={styles.tagGradient} > - {ingredient} + {item.food_name} ))} @@ -1122,9 +1120,9 @@ const TempMealRecommendScreen: React.FC = () => { - {excludedFoods.map((ingredient, index) => ( + {excludedFoods.map((item, index) => ( { - {ingredient} + + {item.food_name} + handleRemoveExcludedIngredient(ingredient)} + onPress={() => + handleRemoveExcludedIngredient(item.id, item.food_name) + } activeOpacity={0.7} > diff --git a/src/services/userPreferencesAPI.ts b/src/services/userPreferencesAPI.ts index e3fa638..9ce9af2 100644 --- a/src/services/userPreferencesAPI.ts +++ b/src/services/userPreferencesAPI.ts @@ -1,74 +1,122 @@ // src/services/userPreferencesAPI.ts import { request } from "./apiConfig"; -import type { UserPreferencesResponse } from "../types"; +// 🚨 [수정됨] DeleteExclusionResponse 타입 추가 Import +import type { + UserPreferencesResponse, + ExclusionResponse, + DeleteExclusionResponse, +} from "../types"; export const userPreferencesAPI = { + // ... 기존 getUserPreferences 등 조회 관련 코드는 그대로 유지 ... + getUserPreferences: async (): Promise => { + // (기존 코드 생략 - 필요시 유지) + return {} as UserPreferencesResponse; // 임시 반환값 (실제 코드에 맞게 유지하세요) + }, + /** - * 사용자 전체 선호도 조회 - * GET /api/user/preferences + * [변경됨] 비선호(제외) 식단 추가 + * POST /exclusions/{user_id}?food_name=...&food_name=...&reason=taste + * @param userId 사용자 ID (문자열) + * @param foods 추가할 음식 이름 배열 (예: ["굴비", "다랑어"]) */ - getUserPreferences: async (): Promise => { + addExclusions: async ( + userId: string, + foods: string[] + ): Promise => { try { - console.log("🍽️ 사용자 선호도 조회"); + console.log(`🚫 비선호 식단 추가 요청 (User: ${userId})`); + console.log("target foods:", foods); - const response = await request( - "/api/user/preferences", - { - method: "GET", - } - ); + // 1. 쿼리 파라미터 생성 + const queryParams = new URLSearchParams(); + + foods.forEach((food) => { + queryParams.append("food_name", food); + }); + + // reason 고정값 추가 + queryParams.append("reason", "taste"); + + const queryString = queryParams.toString(); + const url = `/exclusions/${userId}?${queryString}`; - console.log("✅ 선호도 조회 성공:", response); + console.log("🔗 생성된 URL:", url); + + // 2. 요청 전송 + const response = await request(url, { + method: "POST", + }); + + console.log("✅ 비선호 식단 추가 성공:", response); return response; } catch (error: any) { - console.error("❌ 선호도 조회 실패:", error); - throw new Error(error.message || "사용자 선호도 조회에 실패했습니다."); + console.error("❌ 비선호 식단 추가 실패:", error); + throw new Error(error.message || "비선호 식단 추가에 실패했습니다."); } }, /** - * 비선호 음식만 가져오기 (편의 함수) + * (참고) 기존 편의 함수 업데이트 + * 이제 API가 '전체 덮어쓰기'가 아니라 '추가' 방식이므로 userId를 받도록 수정됨 + */ + addDislikedFoods: async (userId: string, newItems: string[]) => { + return await userPreferencesAPI.addExclusions(userId, newItems); + }, + + /** + * 📋 비선호(제외) 식단 목록 조회 + * GET /exclusions/{user_id} */ - getDislikedFoods: async (): Promise => { + getExclusions: async (userId: string): Promise => { try { - const preferences = await userPreferencesAPI.getUserPreferences(); - return preferences.dislikedFoods || []; + if (!userId || typeof userId !== "string") { + console.warn( + "⚠️ 경고: user_id가 올바르지 않습니다. 문자열 ID(예: ehdrb)를 사용해주세요." + ); + } + + console.log(`📋 비선호 식단 조회 요청 (User: ${userId})`); + + const response = await request( + `/exclusions/${userId}`, + { + method: "GET", + } + ); + + console.log("✅ 비선호 식단 조회 성공:", response); + return response; } catch (error: any) { - console.error("❌ 비선호 음식 조회 실패:", error); - return []; + console.error("❌ 비선호 식단 조회 실패:", error); + throw new Error( + error.message || "비선호 식단 목록을 불러오는데 실패했습니다." + ); } }, /** - * 금지 식단 전체 저장 (덮어쓰기) - * POST /api/user/preferences/disliked - * Body: { "dislikedFoods": ["땅콩", "우유", "새우"] } + * 🗑️ 비선호(제외) 식단 삭제 + * DELETE /exclusions/{exclusion_id} */ - saveDislikedFoods: async ( - dislikedFoods: string[] - ): Promise<{ dislikedFoods: string[] }> => { + deleteExclusion: async ( + exclusionId: number + ): Promise => { try { - console.log("💾 금지 식단 전체 저장:", dislikedFoods); - - const requestBody = { - dislikedFoods, - }; - - console.log("📤 요청 본문:", requestBody); + console.log(`🗑️ 비선호 식단 삭제 요청 (ID: ${exclusionId})`); - const response = await request<{ dislikedFoods: string[] }>( - "/api/user/preferences/disliked", + const response = await request( + `/exclusions/${exclusionId}`, { - method: "POST", - body: JSON.stringify(requestBody), + method: "DELETE", } ); - console.log("✅ 금지 식단 저장 성공:", response); + console.log("✅ 비선호 식단 삭제 성공:", response); return response; } catch (error: any) { - console.error("❌ 금지 식단 저장 실패:", error); - throw new Error(error.message || "금지 식단 저장에 실패했습니다."); + console.error(`❌ 비선호 식단 삭제 실패 (ID: ${exclusionId}):`, error); + throw new Error(error.message || "비선호 식단 삭제에 실패했습니다."); } }, }; diff --git a/src/types/index.ts b/src/types/index.ts index 0ba1044..3e2fd07 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -210,18 +210,21 @@ export interface UserPreferencesResponse { } // 비선호 음식 추가 응답 타입 -export interface AddDislikedFoodResponse { - success: boolean; - message?: string; - dislikedFoods: string[]; +export interface ExclusionResponse { + id: number; + food_name: string; + reason: string; // "taste" } - // 비선호 음식 삭제 응답 타입 export interface RemoveDislikedFoodResponse { success: boolean; message?: string; dislikedFoods: string[]; } +//비선호 음식 삭제 응답 타입 +export interface DeleteExclusionResponse { + status: string; // 예: "deleted" +} // 홈 화면 응답 타입 export interface HomeResponse { userSummary: { From 7610b095c8bed49f9f844b1fb30daf2a253399fb Mon Sep 17 00:00:00 2001 From: donggyu412 Date: Sun, 14 Dec 2025 20:58:35 +0900 Subject: [PATCH 2/2] bugfix --- src/components/modals/DislikedFoodsModal.tsx | 97 +++++++--- src/screens/diet/MealRecommendScreen.tsx | 75 ++++---- src/screens/diet/TempMealRecommendScreen.tsx | 1 - src/screens/exercise/ExerciseScreen.tsx | 186 +++++++++++++------ src/services/apiConfig.ts | 45 +++-- src/services/userPreferencesAPI.ts | 119 ++++++------ 6 files changed, 323 insertions(+), 200 deletions(-) diff --git a/src/components/modals/DislikedFoodsModal.tsx b/src/components/modals/DislikedFoodsModal.tsx index 6e29994..bd5b277 100644 --- a/src/components/modals/DislikedFoodsModal.tsx +++ b/src/components/modals/DislikedFoodsModal.tsx @@ -23,8 +23,8 @@ const { width } = Dimensions.get("window"); interface DislikedFoodsModalProps { visible: boolean; onClose: () => void; - userId: string; // [필수] API 호출을 위한 유저 ID (예: "ehdrb") - onUpdate?: () => void; // 데이터 변경 시 부모에게 알림 (선택 사항) + userId: string; + onUpdate?: () => void; } const DislikedFoodsModal = ({ @@ -33,28 +33,27 @@ const DislikedFoodsModal = ({ userId, onUpdate, }: DislikedFoodsModalProps) => { - // 데이터 구조 변경: 단순 string[] -> { id, food_name, reason }[] const [dislikedFoods, setDislikedFoods] = useState([]); const [inputValue, setInputValue] = useState(""); - - // 로딩 상태 분리 (목록 로드 vs 작업 처리) const [initialLoading, setInitialLoading] = useState(false); const [processing, setProcessing] = useState(false); - // 모달 열릴 때 목록 새로고침 useEffect(() => { if (visible && userId) { loadDislikedFoods(); } }, [visible, userId]); + /** + * 비선호 식단 목록 불러오기 + */ const loadDislikedFoods = async () => { try { setInitialLoading(true); const foods = await userPreferencesAPI.getExclusions(userId); setDislikedFoods(foods); } catch (error: any) { - console.error("Failed to load disliked foods", error); + console.error("비선호 식단 조회 실패:", error); Alert.alert("오류", "금지 식단을 불러오는데 실패했습니다."); } finally { setInitialLoading(false); @@ -62,7 +61,9 @@ const DislikedFoodsModal = ({ }; /** - * 음식 추가 핸들러 (API 즉시 호출) + * 음식 추가 핸들러 + * - 쉼표로 구분된 여러 음식을 한 번에 입력 가능 + * - 예: "굴비, 다랑어" 또는 "오이" */ const handleAdd = async () => { const trimmed = inputValue.trim(); @@ -72,12 +73,32 @@ const DislikedFoodsModal = ({ return; } - // 중복 검사 (이름 기준) - const isDuplicate = dislikedFoods.some( - (item) => item.food_name === trimmed + // 쉼표로 구분하여 배열로 변환 (공백 제거) + const foodsToAdd = trimmed + .split(",") + .map((food) => food.trim()) + .filter((food) => food.length > 0); + + if (foodsToAdd.length === 0) { + Alert.alert("알림", "유효한 음식 이름을 입력해주세요."); + return; + } + + // 중복 검사 (기존 목록의 food_name과 비교) + const duplicates = foodsToAdd.filter((newFood) => + dislikedFoods.some((item) => { + // item.food_name이 "굴비, 다랑어" 형태일 수 있으므로 + // 쉼표로 분리해서 각각 비교 + const existingFoods = item.food_name.split(",").map((f) => f.trim()); + return existingFoods.includes(newFood); + }) ); - if (isDuplicate) { - Alert.alert("알림", "이미 목록에 있는 음식입니다."); + + if (duplicates.length > 0) { + Alert.alert( + "알림", + `이미 목록에 있는 음식입니다: ${duplicates.join(", ")}` + ); return; } @@ -85,16 +106,22 @@ const DislikedFoodsModal = ({ setProcessing(true); Keyboard.dismiss(); - // [API] 추가 요청 - const newFood = await userPreferencesAPI.addExclusions(userId, [trimmed]); + // API 호출: 배열 전달 + const newFood = await userPreferencesAPI.addExclusions( + userId, + foodsToAdd + ); - // 목록 갱신 + // 응답: { id: 1, food_name: "굴비, 다랑어", reason: "taste" } + // 목록에 추가 setDislikedFoods((prev) => [...prev, newFood]); setInputValue(""); + Alert.alert("완료", `"${newFood.food_name}"이(가) 추가되었습니다.`); + if (onUpdate) onUpdate(); } catch (error: any) { - console.error("Add failed", error); + console.error("음식 추가 실패:", error); Alert.alert("오류", "음식을 추가하지 못했습니다."); } finally { setProcessing(false); @@ -102,9 +129,9 @@ const DislikedFoodsModal = ({ }; /** - * 음식 삭제 핸들러 (API 즉시 호출) - * @param id 삭제할 항목의 고유 ID - * @param name 표시용 음식 이름 + * 음식 삭제 핸들러 + * @param id exclusion_id (비선호 식단 저장한 식단의 id) + * @param name 표시용 음식 이름 (food_name) */ const handleRemove = (id: number, name: string) => { Alert.alert("삭제", `"${name}"을(를) 제외 목록에서 삭제하시겠습니까?`, [ @@ -116,15 +143,17 @@ const DislikedFoodsModal = ({ try { setProcessing(true); - // [API] 삭제 요청 (ID 사용) + // API 호출: exclusion_id로 삭제 await userPreferencesAPI.deleteExclusion(id); // 로컬 상태 업데이트 setDislikedFoods((prev) => prev.filter((item) => item.id !== id)); + Alert.alert("완료", "삭제되었습니다."); + if (onUpdate) onUpdate(); } catch (error: any) { - console.error("Delete failed", error); + console.error("삭제 실패:", error); Alert.alert("오류", "삭제에 실패했습니다."); } finally { setProcessing(false); @@ -161,7 +190,9 @@ const DislikedFoodsModal = ({ - 추가된 음식은 즉시 서버에 저장되며 식단 추천에서 제외됩니다. + 쉼표(,)로 구분하여 여러 음식을 한 번에 추가할 수 있습니다. + {"\n"} + 예: 굴비, 다랑어, 오이 @@ -182,7 +213,7 @@ const DislikedFoodsModal = ({ /> 아직 추가된 금지 식단이 없습니다. + + 위 입력창에서 원하는 음식을 추가해보세요. + ) : ( @@ -254,7 +288,6 @@ const DislikedFoodsModal = ({ - {/* item.food_name 사용 */} {item.food_name} @@ -278,14 +311,13 @@ const DislikedFoodsModal = ({ ))} )} - {/* 하단 여백 */} )} - {/* 닫기 버튼 (이제 '저장'이 아니라 단순 '닫기' 역할) */} + {/* 닫기 버튼 */} - {/* 헤더 */} 끼니 수 선택 @@ -279,7 +283,6 @@ const MealsSelectionModal = ({ 하루에 몇 끼를 드시나요? - {/* 끼니 선택 버튼들 */} {[1, 2, 3].map((num) => ( - {/* 닫기 버튼 */} { - // console.log(`🔄 ${dayIndex}일차 변환 시작`); // 로그 줄임 - let breakfast, lunch, dinner; if (tempDay.meals && tempDay.meals.length > 0) { @@ -471,7 +471,6 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { dinner = tempDay.meals.find((m: any) => m.mealType === "DINNER"); if (!breakfast && !lunch && !dinner) { - // console.log(`⚠️ ${dayIndex}일차는 SNACK만 있음 - 변환 시작`); const snacks = tempDay.meals.filter((m: any) => m.mealType === "SNACK"); if (snacks.length >= 1) { @@ -588,7 +587,7 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { const MealRecommendScreen = () => { const navigation = useNavigation(); - const userId = "ehdrb"; + const [userId, setUserId] = useState(""); const [screen, setScreen] = useState<"welcome" | "meals">("welcome"); const [showDislikedModal, setShowDislikedModal] = useState(false); @@ -596,7 +595,6 @@ const MealRecommendScreen = () => { const [weeklyMeals, setWeeklyMeals] = useState([]); const [currentDay, setCurrentDay] = useState(0); - // 🔹 [수정] string[] -> ExclusionResponse[] (객체 배열) const [excludedIngredients, setExcludedIngredients] = useState< ExclusionResponse[] >([]); @@ -605,7 +603,6 @@ const MealRecommendScreen = () => { const [savedMeals, setSavedMeals] = useState([]); const [currentPlanId, setCurrentPlanId] = useState(null); - // ✅ 끼니 수 관련 state const [mealsPerDay, setMealsPerDay] = useState(3); const [showMealsModal, setShowMealsModal] = useState(false); @@ -619,10 +616,10 @@ const MealRecommendScreen = () => { }).start(); }, [screen]); - // 🔹 [추가] 비선호 식단 목록 로드 함수 - const loadExclusions = async () => { + // 🔹 비선호 식단 목록 로드 함수 - userId를 파라미터로 받음 + const loadExclusions = async (currentUserId: string) => { try { - const data = await userPreferencesAPI.getExclusions(userId); + const data = await userPreferencesAPI.getExclusions(currentUserId); setExcludedIngredients(data); console.log("✅ 비선호 음식 로드 완료:", data.length, "개"); } catch (error) { @@ -630,21 +627,32 @@ const MealRecommendScreen = () => { } }; - useEffect(() => { - const loadData = async () => { - try { - console.log("========== 초기 데이터 로드 =========="); + // 🔹 유저 데이터 로드 함수 + const loadUserData = async () => { + try { + // 1. 프로필 조회하여 userId 획득 + const profile = await authAPI.getProfile(); + const currentUserId = profile.userId; + setUserId(currentUserId); + console.log("✅ 유저 ID 로드 완료:", currentUserId); - // 비선호 식단 불러오기 - await loadExclusions(); + // 2. 획득한 userId로 비선호 식단 조회 + await loadExclusions(currentUserId); - // 저장된 식단 불러오기 - await loadSavedMeals(); - } catch (error) { - console.error("데이터 로드 실패:", error); - } + // 3. 저장된 식단 불러오기 + await loadSavedMeals(); + } catch (error) { + console.error("사용자 데이터 로드 실패:", error); + } + }; + + // 🔹 useEffect에서 loadUserData 호출 + useEffect(() => { + const init = async () => { + console.log("========== 초기 데이터 로드 =========="); + await loadUserData(); }; - loadData(); + init(); }, []); const loadSavedMeals = async () => { @@ -968,15 +976,13 @@ const MealRecommendScreen = () => { onCancel={handleCancelLoading} /> - {/* 🔹 [수정] 모달 연결: userId 및 onUpdate 전달 */} setShowDislikedModal(false)} - onUpdate={loadExclusions} + onUpdate={() => loadExclusions(userId)} /> - {/* ✅ 끼니 선택 모달 */} { - {/* 1. 7일 추천 식단 받기 */} { - {/* 2. 1일 추천 식단 받기 */} { - {/* 3. 금지 식재료 관리 */} setShowDislikedModal(true)} @@ -1095,7 +1098,6 @@ const MealRecommendScreen = () => { - {/* ✅ 4. 끼니 수정하기 */} setShowMealsModal(true)} @@ -1118,7 +1120,6 @@ const MealRecommendScreen = () => { - {/* 🔹 [수정] 제외된 식재료 미리보기 (객체 구조 반영) */} {excludedIngredients.length > 0 && ( @@ -1133,7 +1134,6 @@ const MealRecommendScreen = () => { ]} style={styles.tagGradient} > - {/* item.food_name 사용 */} {item.food_name} @@ -1181,7 +1181,6 @@ const MealRecommendScreen = () => { setScreen("meals"); setCurrentDay(0); } else { - // TODO: 서버 식단 상세 보기 구현 시 연결 navigation.navigate("MealRecommendHistory" as never); } }} @@ -1422,7 +1421,7 @@ const MealRecommendScreen = () => { - {/* ✅ 아침 */} + {/* 아침 */} { - {/* ✅ 점심 */} + {/* 점심 */} { - {/* ✅ 저녁 */} + {/* 저녁 */} { const navigation = useNavigation(); - // 🔹 [변경] 하드코딩 제거 후 state로 관리 const [userId, setUserId] = useState(""); const [screen, setScreen] = useState< diff --git a/src/screens/exercise/ExerciseScreen.tsx b/src/screens/exercise/ExerciseScreen.tsx index ddda9a9..b736c17 100644 --- a/src/screens/exercise/ExerciseScreen.tsx +++ b/src/screens/exercise/ExerciseScreen.tsx @@ -218,14 +218,36 @@ const ExerciseScreen = ({ navigation }: any) => { const selectedDateStr = `${selectedDate.getFullYear()}-${String( selectedDate.getMonth() + 1 ).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}`; + + // 🔍 디버깅 로그 + console.log("🔍 activities 필터링:", { + selectedDateStr, + allActivitiesCount: allActivities.length, + // AI 추천 운동이 있는지 확인 + aiActivities: allActivities.filter((a) => a.date === selectedDateStr), + }); + return allActivities.filter( (activity) => activity.date === selectedDateStr ); }, [allActivities, selectedDate]); - const workoutActivities = React.useMemo( - () => activities.filter((activity) => !isStretchActivity(activity)), - [activities] - ); + const workoutActivities = React.useMemo(() => { + const filtered = activities.filter( + (activity) => !isStretchActivity(activity) + ); + + // 🔍 디버깅 로그 + console.log("🏋️ workoutActivities 필터링:", { + activitiesCount: activities.length, + workoutActivitiesCount: filtered.length, + // AI 추천 운동이 포함되는지 확인 + aiWorkouts: filtered.filter((a) => a.saveTitle?.includes("AI")), + // 스트레칭으로 제외된 운동 + stretchActivities: activities.filter((a) => isStretchActivity(a)), + }); + + return filtered; + }, [activities]); const [savedWorkouts, setSavedWorkouts] = useState([]); const [savedWorkoutsLoading, setSavedWorkoutsLoading] = useState(false); @@ -582,11 +604,30 @@ const ExerciseScreen = ({ navigation }: any) => { // 완료되지 않은 운동이 있는지 확인 const hasIncompleteActivities = React.useMemo(() => { - return workoutActivities.some( + const result = workoutActivities.some( (activity) => !isActivityFullyCompleted(activity) ); - }, [workoutActivities, isActivityFullyCompleted]); + // 🔍 디버깅: 각 운동의 완료 상태 확인 + console.log("🎯 시작 버튼 조건:", { + hasIncomplete: result, + workoutActivitiesLength: workoutActivities.length, + // 각 운동별 상세 정보 + details: workoutActivities.map((a) => ({ + name: a.name, + isCompleted: a.isCompleted, + setsLength: a.sets?.length || 0, + setsDetail: a.sets?.map((s: any) => ({ + isCompleted: s.isCompleted, + weight: s.weight, + reps: s.reps, + })), + fullyCompleted: isActivityFullyCompleted(a), + })), + }); + + return result; + }, [workoutActivities, isActivityFullyCompleted]); const goalSummaryText = React.useMemo(() => { if (!goalData) { return "목표치가 아직 설정되지 않았습니다"; @@ -2517,32 +2558,32 @@ const ExerciseScreen = ({ navigation }: any) => { }; const handleStartWorkoutSequence = () => { - // 저장된 운동(saveTitle이 있는)은 제외하고 진행 가능한 운동만 필터링 + console.log("🚀 handleStartWorkoutSequence 실행 시작!"); + console.log("workoutActivities:", workoutActivities); + + // ✅ 수정: 저장된 세션(완료된 운동)만 제외 const availableActivities = workoutActivities.filter( - (activity) => !activity.saveTitle || activity.saveTitle.trim() === "" + (activity) => !isActivityFullyCompleted(activity) ); + console.log("🏃 시작 가능한 운동:", availableActivities.length); + if (availableActivities.length === 0) { + console.log("⚠️ 시작 가능한 운동이 없어서 운동 추가 모달 열기"); handleWorkoutStartPress(); return; } - // 운동 시작 시점의 누적 시간 기록 (현재 세션 시간 계산을 위해) + console.log("✅ 운동 시작:", availableActivities[0].name); + + // 운동 시작 시점의 누적 시간 기록 setWorkoutStartTime(todayTotalWorkoutSeconds); - // 현재 운동의 시작 시간도 기록 (각 운동별 시간 추적) setCurrentExerciseStartTime(todayTotalWorkoutSeconds); - // 실제 타임스탬프 기록 (실제 운동 시간 계산용) setCurrentExerciseStartTimestamp(Date.now()); - // 아직 완료하지 않은 첫 번째 운동을 찾음 - const nextActivity = - availableActivities.find( - (activity) => !isActivityFullyCompleted(activity) - ) || availableActivities[0]; - - const nextIndex = availableActivities.findIndex( - (activity) => activity.id === nextActivity.id - ); + // 첫 번째 미완료 운동 선택 + const nextActivity = availableActivities[0]; + const nextIndex = 0; // 이미지 찾기 const resolvedImageUrl = @@ -2554,20 +2595,17 @@ const ExerciseScreen = ({ navigation }: any) => { ? exerciseImagesByName[nextActivity.name.toLowerCase()] : null); - // ✅이미지 포함된 객체 생성 const activityWithImage = { ...nextActivity, imageUrl: resolvedImageUrl || undefined, }; - // 저장된 운동을 제외한 운동 목록만 시퀀스에 설정 setExerciseSequence(availableActivities); setExerciseSequenceIndex(nextIndex); setModalMode("edit"); setSelectedExercise(activityWithImage); setIsModalOpen(true); }; - const handleSequenceNavigate = (direction: "prev" | "next") => { if (!exerciseSequence.length) return; const delta = direction === "next" ? 1 : -1; @@ -2867,14 +2905,12 @@ const ExerciseScreen = ({ navigation }: any) => { // AI 추천 운동 수신 useFocusEffect( React.useCallback(() => { - // 1. 로딩 대기 if (!initialLoadComplete) return; - // 2. 파라미터 구조 유연하게 확인 (route.params 또는 route.params.params) const rawParams = (route.params as any) || {}; const recommendedExercises = rawParams.recommendedExercises || - rawParams.params?.recommendedExercises; // 👈 여기가 핵심! 한 단계 더 깊이 확인 + rawParams.params?.recommendedExercises; if ( recommendedExercises && @@ -2885,7 +2921,13 @@ const ExerciseScreen = ({ navigation }: any) => { `🚀 [AI] 추천 운동 ${recommendedExercises.length}개 수신 성공!` ); - // 3. 날짜 동기화 + // 🔍 디버깅: AI 추천 운동의 구조 확인 + console.log( + "🔍 AI 추천 운동 첫 번째:", + JSON.stringify(recommendedExercises[0], null, 2) + ); + + // 날짜 동기화 const routineDateStr = recommendedExercises[0].date; if (routineDateStr) { const [year, month, day] = routineDateStr.split("-").map(Number); @@ -2900,29 +2942,34 @@ const ExerciseScreen = ({ navigation }: any) => { } } - // 4. 데이터 추가 - setAllActivities((prev) => { - const existingGroupKey = recommendedExercises[0]?.groupKey; - const alreadyExists = prev.some( - (activity) => activity.groupKey === existingGroupKey - ); + // ⏱️ 약간의 딜레이 후 운동 추가 (State 업데이트 타이밍 보장) + setTimeout(() => { + setAllActivities((prev) => { + const existingGroupKey = recommendedExercises[0]?.groupKey; + const alreadyExists = prev.some( + (activity) => activity.groupKey === existingGroupKey + ); - if (alreadyExists) { - console.log("⚠️ [AI] 이미 추가된 루틴입니다."); - return prev; - } + if (alreadyExists) { + console.log("⚠️ [AI] 이미 추가된 루틴입니다."); + return prev; + } - console.log("✅ [AI] 리스트에 운동 추가 완료!"); - return [...prev, ...recommendedExercises]; - }); + console.log("✅ [AI] 리스트에 운동 추가 완료!"); + console.log( + "🔍 추가 후 전체 개수:", + prev.length + recommendedExercises.length + ); + + return [...prev, ...recommendedExercises]; + }); + }, 100); // 👈 100ms 딜레이 추가 - // 5. 파라미터 초기화 (재실행 방지) navigation.setParams({ recommendedExercises: undefined, - params: undefined, // 중첩된 params도 초기화 + params: undefined, }); - // 6. 알림 setTimeout(() => { Alert.alert( "루틴 추가 완료", @@ -3882,6 +3929,7 @@ const ExerciseScreen = ({ navigation }: any) => { + {/* 운동 기록 섹션 */} {/* 운동 기록 섹션 */} @@ -3889,14 +3937,48 @@ const ExerciseScreen = ({ navigation }: any) => { - {hasIncompleteActivities && workoutActivities.length > 0 ? ( - - 시작 - - ) : null} + {/* 🔍 디버깅: 조건 확인 */} + {(() => { + const shouldShow = + hasIncompleteActivities && workoutActivities.length > 0; + console.log("🚨 시작 버튼 렌더링 체크:", { + hasIncompleteActivities, + workoutActivitiesLength: workoutActivities.length, + shouldShow, + }); + + if (shouldShow) { + console.log("✅ 시작 버튼 렌더링됨!"); + } else { + console.log("❌ 시작 버튼 조건 불만족"); + } + + return shouldShow ? ( + { + console.log("🎯🎯🎯 시작 버튼 클릭됨!"); + console.log( + "workoutActivities:", + workoutActivities.length + ); + handleStartWorkoutSequence(); + }} + > + + 시작 + + + ) : null; + })()} @@ -3950,7 +4032,7 @@ const ExerciseScreen = ({ navigation }: any) => { diff --git a/src/services/apiConfig.ts b/src/services/apiConfig.ts index 6a25b97..4fac13d 100644 --- a/src/services/apiConfig.ts +++ b/src/services/apiConfig.ts @@ -29,6 +29,12 @@ export const request = async ( // 저장된 토큰 가져오기 const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + // 🔍 토큰 디버깅 로그 추가 + console.log( + "🔑 토큰 확인:", + token ? `토큰 있음 (${token.substring(0, 20)}...)` : "❌ 토큰 없음" + ); + // 요청 헤더 설정 const headers: HeadersInit = { "Content-Type": "application/json", @@ -42,27 +48,30 @@ export const request = async ( try { // 서버에 요청 전송 - console.log("API 요청:", `${API_BASE_URL}${endpoint}`); + // 📌 한글로 디코딩된 URL 로그 + const fullUrl = `${API_BASE_URL}${endpoint}`; + const decodedUrl = decodeURIComponent(fullUrl); + console.log("API 요청:", decodedUrl); console.log("요청 옵션:", { method: options.method || "GET", headers }); // POST/PUT 요청인 경우 본문도 로깅 - if ( - options.body && - (options.method === "POST" || - options.method === "PUT" || - options.method === "PATCH" || - options.method === "DELETE") - ) { - try { - const bodyData = - typeof options.body === "string" - ? JSON.parse(options.body) - : options.body; - console.log("요청 본문:", JSON.stringify(bodyData, null, 2)); - } catch (e) { - console.log("요청 본문 (원본):", options.body); - } -} + if ( + options.body && + (options.method === "POST" || + options.method === "PUT" || + options.method === "PATCH" || + options.method === "DELETE") + ) { + try { + const bodyData = + typeof options.body === "string" + ? JSON.parse(options.body) + : options.body; + console.log("요청 본문:", JSON.stringify(bodyData, null, 2)); + } catch (e) { + console.log("요청 본문 (원본):", options.body); + } + } const response = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, diff --git a/src/services/userPreferencesAPI.ts b/src/services/userPreferencesAPI.ts index 9ce9af2..920339e 100644 --- a/src/services/userPreferencesAPI.ts +++ b/src/services/userPreferencesAPI.ts @@ -1,24 +1,49 @@ // src/services/userPreferencesAPI.ts -import { request } from "./apiConfig"; -// 🚨 [수정됨] DeleteExclusionResponse 타입 추가 Import -import type { - UserPreferencesResponse, - ExclusionResponse, - DeleteExclusionResponse, -} from "../types"; +import { requestAI } from "./apiConfig"; +import type { ExclusionResponse, DeleteExclusionResponse } from "../types"; export const userPreferencesAPI = { - // ... 기존 getUserPreferences 등 조회 관련 코드는 그대로 유지 ... - getUserPreferences: async (): Promise => { - // (기존 코드 생략 - 필요시 유지) - return {} as UserPreferencesResponse; // 임시 반환값 (실제 코드에 맞게 유지하세요) + /** + * 📋 비선호(제외) 식단 목록 조회 + * GET /exclusions/{user_id} + * @param userId 사용자 ID (문자열, 예: "ehdrb") + * @returns 비선호 식단 목록 배열 + * + * 응답 예시: + * [ + * { id: 1, food_name: "굴비, 다랑어", reason: "taste" } + * ] + */ + getExclusions: async (userId: string): Promise => { + try { + console.log(`📋 비선호 식단 조회 요청 (User: ${userId})`); + + const response = await requestAI( + `/exclusions/${userId}`, + { + method: "GET", + } + ); + + console.log("✅ 비선호 식단 조회 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 비선호 식단 조회 실패:", error); + throw new Error( + error.message || "비선호 식단 목록을 불러오는데 실패했습니다." + ); + } }, /** - * [변경됨] 비선호(제외) 식단 추가 - * POST /exclusions/{user_id}?food_name=...&food_name=...&reason=taste + * 🚫 비선호(제외) 식단 추가 + * POST /exclusions/{user_id}?food_name=굴비&food_name=다랑어&reason=taste * @param userId 사용자 ID (문자열) * @param foods 추가할 음식 이름 배열 (예: ["굴비", "다랑어"]) + * @returns 추가된 비선호 식단 정보 + * + * 응답 예시: + * { id: 1, food_name: "굴비, 다랑어", reason: "taste" } */ addExclusions: async ( userId: string, @@ -26,25 +51,31 @@ export const userPreferencesAPI = { ): Promise => { try { console.log(`🚫 비선호 식단 추가 요청 (User: ${userId})`); - console.log("target foods:", foods); + console.log("추가할 음식:", foods); - // 1. 쿼리 파라미터 생성 + // 쿼리 파라미터 생성 const queryParams = new URLSearchParams(); - foods.forEach((food) => { queryParams.append("food_name", food); }); - - // reason 고정값 추가 queryParams.append("reason", "taste"); const queryString = queryParams.toString(); const url = `/exclusions/${userId}?${queryString}`; - console.log("🔗 생성된 URL:", url); + // 📌 한글로 디코딩된 URL 로그 출력 + const decodedUrl = `/exclusions/${userId}?${decodeURIComponent( + queryString + )}`; + console.log("🔗 요청 URL:", decodedUrl); + // 또는 더 명확하게 + console.log( + `POST /exclusions/${userId}?${foods + .map((f) => `food_name=${f}`) + .join("&")}&reason=taste` + ); - // 2. 요청 전송 - const response = await request(url, { + const response = await requestAI(url, { method: "POST", }); @@ -56,48 +87,14 @@ export const userPreferencesAPI = { } }, - /** - * (참고) 기존 편의 함수 업데이트 - * 이제 API가 '전체 덮어쓰기'가 아니라 '추가' 방식이므로 userId를 받도록 수정됨 - */ - addDislikedFoods: async (userId: string, newItems: string[]) => { - return await userPreferencesAPI.addExclusions(userId, newItems); - }, - - /** - * 📋 비선호(제외) 식단 목록 조회 - * GET /exclusions/{user_id} - */ - getExclusions: async (userId: string): Promise => { - try { - if (!userId || typeof userId !== "string") { - console.warn( - "⚠️ 경고: user_id가 올바르지 않습니다. 문자열 ID(예: ehdrb)를 사용해주세요." - ); - } - - console.log(`📋 비선호 식단 조회 요청 (User: ${userId})`); - - const response = await request( - `/exclusions/${userId}`, - { - method: "GET", - } - ); - - console.log("✅ 비선호 식단 조회 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 비선호 식단 조회 실패:", error); - throw new Error( - error.message || "비선호 식단 목록을 불러오는데 실패했습니다." - ); - } - }, - /** * 🗑️ 비선호(제외) 식단 삭제 * DELETE /exclusions/{exclusion_id} + * @param exclusionId 비선호 식단 저장한 식단의 id (조회 시 받은 id 값) + * @returns 삭제 결과 + * + * 응답 예시: + * { status: "deleted" } */ deleteExclusion: async ( exclusionId: number @@ -105,7 +102,7 @@ export const userPreferencesAPI = { try { console.log(`🗑️ 비선호 식단 삭제 요청 (ID: ${exclusionId})`); - const response = await request( + const response = await requestAI( `/exclusions/${exclusionId}`, { method: "DELETE",