diff --git a/src/components/modals/ChatbotSettingsModal.tsx b/src/components/modals/ChatbotSettingsModal.tsx index 835df54..57b980c 100644 --- a/src/components/modals/ChatbotSettingsModal.tsx +++ b/src/components/modals/ChatbotSettingsModal.tsx @@ -1,364 +1,364 @@ -import React, { useState } from "react"; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - Modal, - ScrollView, -} from "react-native"; -import { Ionicons as Icon } from "@expo/vector-icons"; - -interface ChatbotSettingsModalProps { - isOpen: boolean; - onClose: () => void; - currentMode: "auto" | "exercise" | "nutrition"; - currentStyle: "pro" | "friend" | "soft" | "drill"; - onSave: (mode: string, style: string) => void; -} - -const ChatbotSettingsModal: React.FC = ({ - isOpen, - onClose, - currentMode, - currentStyle, - onSave, -}) => { - const [selectedMode, setSelectedMode] = useState(currentMode); - const [selectedStyle, setSelectedStyle] = useState(currentStyle); - - const modes = [ - { - value: "auto", - label: "자동", - icon: "sparkles", - description: "상황에 맞춰 자동으로 답변", - }, - { - value: "exercise", - label: "운동", - icon: "barbell", - description: "운동 관련 전문 답변", - }, - { - value: "nutrition", - label: "영양", - icon: "nutrition", - description: "식단/영양 전문 답변", - }, - ]; - - const styles_data = [ - { - value: "pro", - label: "프로페셔널", - icon: "briefcase", - description: "전문적이고 체계적인 톤", - }, - { - value: "friend", - label: "친근한 친구", - icon: "happy", - description: "편안하고 친근한 톤", - }, - { - value: "soft", - label: "부드럽게", - icon: "heart", - description: "따뜻하고 격려하는 톤", - }, - { - value: "drill", - label: "엄격한 코치", - icon: "fitness", - description: "강력하고 동기부여하는 톤", - }, - ]; - - const handleSave = () => { - onSave(selectedMode, selectedStyle); - onClose(); - }; - - return ( - - - - {/* 헤더 */} - - 채팅 설정 - - - - - - - {/* 카테고리 섹션 */} - - 📂 카테고리 - - 어떤 주제로 대화할까요? - - - {modes.map((mode) => ( - setSelectedMode(mode.value as any)} - > - - - - {mode.label} - - {selectedMode === mode.value && ( - - )} - - - {mode.description} - - - ))} - - - {/* 대화 스타일 섹션 */} - - 💬 대화 스타일 - - 어떤 톤으로 대화할까요? - - - - {styles_data.map((style) => ( - setSelectedStyle(style.value as any)} - > - - - {style.label} - - - {style.description} - - - ))} - - - - - {/* 버튼 */} - - - 취소 - - - 적용하기 - - - - - - ); -}; - -// ✅ NEW_COLORS 추가 -const NEW_COLORS = { - background: "#1a1a1a", - text: "#f0f0f0", - text_secondary: "#a0a0a0", - accent: "#e3ff7c", - card_bg: "#252525", - separator: "#3a3a3a", - delete_color: "#ff6b6b", -}; - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.5)", - justifyContent: "flex-end", - }, - modalContainer: { - backgroundColor: NEW_COLORS.background, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - maxHeight: "90%", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 20, - borderBottomWidth: 1, - borderBottomColor: NEW_COLORS.separator, - }, - title: { - fontSize: 20, - fontWeight: "bold", - color: NEW_COLORS.text, - }, - content: { - padding: 20, - }, - section: { - marginBottom: 32, - }, - sectionTitle: { - fontSize: 18, - fontWeight: "600", - color: NEW_COLORS.text, - marginBottom: 4, - }, - sectionSubtitle: { - fontSize: 14, - color: NEW_COLORS.text_secondary, - marginBottom: 16, - }, - optionCard: { - backgroundColor: NEW_COLORS.card_bg, - borderRadius: 12, - padding: 16, - marginBottom: 12, - borderWidth: 2, - borderColor: "transparent", - }, - optionCardSelected: { - borderColor: NEW_COLORS.accent, - backgroundColor: `${NEW_COLORS.accent}15`, - }, - optionHeader: { - flexDirection: "row", - alignItems: "center", - gap: 12, - marginBottom: 8, - }, - optionLabel: { - flex: 1, - fontSize: 16, - fontWeight: "600", - color: NEW_COLORS.text, - }, - optionLabelSelected: { - color: NEW_COLORS.accent, - }, - optionDescription: { - fontSize: 13, - color: NEW_COLORS.text_secondary, - marginLeft: 36, - }, - styleGrid: { - flexDirection: "row", - flexWrap: "wrap", - gap: 12, - }, - styleCard: { - width: "48%", - backgroundColor: NEW_COLORS.card_bg, - borderRadius: 12, - padding: 16, - alignItems: "center", - borderWidth: 2, - borderColor: "transparent", - }, - styleCardSelected: { - borderColor: NEW_COLORS.accent, - backgroundColor: `${NEW_COLORS.accent}15`, - }, - styleLabel: { - fontSize: 14, - fontWeight: "600", - color: NEW_COLORS.text, - marginTop: 8, - marginBottom: 4, - }, - styleLabelSelected: { - color: NEW_COLORS.accent, - }, - styleDescription: { - fontSize: 11, - color: NEW_COLORS.text_secondary, - textAlign: "center", - }, - footer: { - flexDirection: "row", - padding: 20, - gap: 12, - borderTopWidth: 1, - borderTopColor: NEW_COLORS.separator, - }, - cancelButton: { - flex: 1, - paddingVertical: 14, - borderRadius: 12, - backgroundColor: NEW_COLORS.card_bg, - alignItems: "center", - }, - cancelButtonText: { - fontSize: 16, - fontWeight: "600", - color: NEW_COLORS.text, - }, - saveButton: { - flex: 1, - paddingVertical: 14, - borderRadius: 12, - backgroundColor: NEW_COLORS.accent, - alignItems: "center", - }, - saveButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#000000", - }, -}); - -export default ChatbotSettingsModal; +import React, { useState } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Modal, + ScrollView, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; + +interface ChatbotSettingsModalProps { + isOpen: boolean; + onClose: () => void; + currentMode: "auto" | "exercise" | "nutrition"; + currentStyle: "pro" | "friend" | "soft" | "drill"; + onSave: (mode: string, style: string) => void; +} + +const ChatbotSettingsModal: React.FC = ({ + isOpen, + onClose, + currentMode, + currentStyle, + onSave, +}) => { + const [selectedMode, setSelectedMode] = useState(currentMode); + const [selectedStyle, setSelectedStyle] = useState(currentStyle); + + const modes = [ + { + value: "auto", + label: "자동", + icon: "sparkles", + description: "상황에 맞춰 자동으로 답변", + }, + { + value: "exercise", + label: "운동", + icon: "barbell", + description: "운동 관련 전문 답변", + }, + { + value: "nutrition", + label: "영양", + icon: "nutrition", + description: "식단/영양 전문 답변", + }, + ]; + + const styles_data = [ + { + value: "pro", + label: "프로페셔널", + icon: "briefcase", + description: "전문적이고 체계적인 톤", + }, + { + value: "friend", + label: "친근한 친구", + icon: "happy", + description: "편안하고 친근한 톤", + }, + { + value: "soft", + label: "부드럽게", + icon: "heart", + description: "따뜻하고 격려하는 톤", + }, + { + value: "drill", + label: "엄격한 코치", + icon: "fitness", + description: "강력하고 동기부여하는 톤", + }, + ]; + + const handleSave = () => { + onSave(selectedMode, selectedStyle); + onClose(); + }; + + return ( + + + + {/* 헤더 */} + + 채팅 설정 + + + + + + + {/* 카테고리 섹션 */} + + 📂 카테고리 + + 어떤 주제로 대화할까요? + + + {modes.map((mode) => ( + setSelectedMode(mode.value as any)} + > + + + + {mode.label} + + {selectedMode === mode.value && ( + + )} + + + {mode.description} + + + ))} + + + {/* 대화 스타일 섹션 */} + + 💬 대화 스타일 + + 어떤 톤으로 대화할까요? + + + + {styles_data.map((style) => ( + setSelectedStyle(style.value as any)} + > + + + {style.label} + + + {style.description} + + + ))} + + + + + {/* 버튼 */} + + + 취소 + + + 적용하기 + + + + + + ); +}; + +// ✅ NEW_COLORS 추가 +const NEW_COLORS = { + background: "#1a1a1a", + text: "#f0f0f0", + text_secondary: "#a0a0a0", + accent: "#e3ff7c", + card_bg: "#252525", + separator: "#3a3a3a", + delete_color: "#ff6b6b", +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + modalContainer: { + backgroundColor: NEW_COLORS.background, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: "90%", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 20, + borderBottomWidth: 1, + borderBottomColor: NEW_COLORS.separator, + }, + title: { + fontSize: 20, + fontWeight: "bold", + color: NEW_COLORS.text, + }, + content: { + padding: 20, + }, + section: { + marginBottom: 32, + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: NEW_COLORS.text, + marginBottom: 4, + }, + sectionSubtitle: { + fontSize: 14, + color: NEW_COLORS.text_secondary, + marginBottom: 16, + }, + optionCard: { + backgroundColor: NEW_COLORS.card_bg, + borderRadius: 12, + padding: 16, + marginBottom: 12, + borderWidth: 2, + borderColor: "transparent", + }, + optionCardSelected: { + borderColor: NEW_COLORS.accent, + backgroundColor: `${NEW_COLORS.accent}15`, + }, + optionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 8, + }, + optionLabel: { + flex: 1, + fontSize: 16, + fontWeight: "600", + color: NEW_COLORS.text, + }, + optionLabelSelected: { + color: NEW_COLORS.accent, + }, + optionDescription: { + fontSize: 13, + color: NEW_COLORS.text_secondary, + marginLeft: 36, + }, + styleGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + }, + styleCard: { + width: "48%", + backgroundColor: NEW_COLORS.card_bg, + borderRadius: 12, + padding: 16, + alignItems: "center", + borderWidth: 2, + borderColor: "transparent", + }, + styleCardSelected: { + borderColor: NEW_COLORS.accent, + backgroundColor: `${NEW_COLORS.accent}15`, + }, + styleLabel: { + fontSize: 14, + fontWeight: "600", + color: NEW_COLORS.text, + marginTop: 8, + marginBottom: 4, + }, + styleLabelSelected: { + color: NEW_COLORS.accent, + }, + styleDescription: { + fontSize: 11, + color: NEW_COLORS.text_secondary, + textAlign: "center", + }, + footer: { + flexDirection: "row", + padding: 20, + gap: 12, + borderTopWidth: 1, + borderTopColor: NEW_COLORS.separator, + }, + cancelButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + backgroundColor: NEW_COLORS.card_bg, + alignItems: "center", + }, + cancelButtonText: { + fontSize: 16, + fontWeight: "600", + color: NEW_COLORS.text, + }, + saveButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + backgroundColor: NEW_COLORS.accent, + alignItems: "center", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#000000", + }, +}); + +export default ChatbotSettingsModal; diff --git a/src/components/modals/DislikedFoodsModal.tsx b/src/components/modals/DislikedFoodsModal.tsx index bd5b277..58a207b 100644 --- a/src/components/modals/DislikedFoodsModal.tsx +++ b/src/components/modals/DislikedFoodsModal.tsx @@ -1,4 +1,3 @@ -// src/components/DislikedFoodsModal.tsx import React, { useState, useEffect } from "react"; import { View, @@ -12,6 +11,8 @@ import { ActivityIndicator, Dimensions, Keyboard, + KeyboardAvoidingView, + Platform, } from "react-native"; import { Ionicons as Icon } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; @@ -44,27 +45,18 @@ const DislikedFoodsModal = ({ } }, [visible, userId]); - /** - * 비선호 식단 목록 불러오기 - */ const loadDislikedFoods = async () => { try { setInitialLoading(true); const foods = await userPreferencesAPI.getExclusions(userId); setDislikedFoods(foods); - } catch (error: any) { - console.error("비선호 식단 조회 실패:", error); + } catch (error) { Alert.alert("오류", "금지 식단을 불러오는데 실패했습니다."); } finally { setInitialLoading(false); } }; - /** - * 음식 추가 핸들러 - * - 쉼표로 구분된 여러 음식을 한 번에 입력 가능 - * - 예: "굴비, 다랑어" 또는 "오이" - */ const handleAdd = async () => { const trimmed = inputValue.trim(); @@ -73,7 +65,6 @@ const DislikedFoodsModal = ({ return; } - // 쉼표로 구분하여 배열로 변환 (공백 제거) const foodsToAdd = trimmed .split(",") .map((food) => food.trim()) @@ -84,11 +75,8 @@ const DislikedFoodsModal = ({ 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); }) @@ -106,18 +94,14 @@ const DislikedFoodsModal = ({ setProcessing(true); Keyboard.dismiss(); - // API 호출: 배열 전달 - const newFood = await userPreferencesAPI.addExclusions( - userId, - foodsToAdd - ); + // ✅ 여러 개를 하나씩 추가 (반복문 내부에서 처리) + await userPreferencesAPI.addExclusions(userId, foodsToAdd); - // 응답: { id: 1, food_name: "굴비, 다랑어", reason: "taste" } - // 목록에 추가 - setDislikedFoods((prev) => [...prev, newFood]); + // 목록 새로고침 + await loadDislikedFoods(); setInputValue(""); - Alert.alert("완료", `"${newFood.food_name}"이(가) 추가되었습니다.`); + Alert.alert("완료", `${foodsToAdd.join(", ")}이(가) 추가되었습니다.`); if (onUpdate) onUpdate(); } catch (error: any) { @@ -128,13 +112,8 @@ const DislikedFoodsModal = ({ } }; - /** - * 음식 삭제 핸들러 - * @param id exclusion_id (비선호 식단 저장한 식단의 id) - * @param name 표시용 음식 이름 (food_name) - */ const handleRemove = (id: number, name: string) => { - Alert.alert("삭제", `"${name}"을(를) 제외 목록에서 삭제하시겠습니까?`, [ + Alert.alert("삭제", `"${name}"을(를) 삭제하시겠습니까?`, [ { text: "취소", style: "cancel" }, { text: "삭제", @@ -142,18 +121,10 @@ const DislikedFoodsModal = ({ onPress: async () => { try { setProcessing(true); - - // 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("삭제 실패:", error); + } catch { Alert.alert("오류", "삭제에 실패했습니다."); } finally { setProcessing(false); @@ -166,178 +137,149 @@ const DislikedFoodsModal = ({ return ( - - - - - {/* 헤더 */} - - - - - 금지 식단 설정 - - + + + - {/* 안내 메시지 */} - - - - 쉼표(,)로 구분하여 여러 음식을 한 번에 추가할 수 있습니다. - {"\n"} - 예: 굴비, 다랑어, 오이 - - + + {/* 헤더 */} + + + + + 금지 식단 설정 + + - - {/* 입력 필드 */} - - 음식 추가 - - - - - - + {/* 안내 */} + + + 쉼표(,)로 여러 음식을 추가할 수 있습니다.{"\n"} + 예: 굴비, 다랑어, 오이 + + + + + {/* 입력 */} + + 음식 추가 + - {processing ? ( - - ) : ( - - )} + + - - - - - {/* 금지 식단 목록 */} - - - 금지 식단 목록 ({dislikedFoods.length}) - - {initialLoading ? ( - - + + + {processing ? ( + + ) : ( + + )} + + - ) : ( - - {dislikedFoods.length === 0 ? ( - - - - 아직 추가된 금지 식단이 없습니다. - - - 위 입력창에서 원하는 음식을 추가해보세요. - - - ) : ( - - {dislikedFoods.map((item) => ( - - - - - - - - {item.food_name} - - - - - handleRemove(item.id, item.food_name) - } - style={styles.removeBtn} - disabled={processing} - > - - - - - ))} + + + {/* 리스트 */} + + {initialLoading ? ( + + + + ) : dislikedFoods.length === 0 ? ( + + + + 아직 추가된 금지 식단이 없습니다. + + + ) : ( + dislikedFoods.map((item) => ( + + + {item.food_name} + handleRemove(item.id, item.food_name)} + > + + + - )} - - - )} + )) + )} + - - {/* 닫기 버튼 */} - - - - 닫기 - - + {/* footer (absolute 제거됨) */} + + + + 닫기 + + + - + ); }; const styles = StyleSheet.create({ - overlay: { - flex: 1, - }, + overlay: { flex: 1 }, container: { flex: 1, marginTop: 50, @@ -349,53 +291,28 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "center", - paddingVertical: 20, - paddingHorizontal: 20, + padding: 20, borderBottomWidth: 1, - borderBottomColor: "#333333", - }, - closeBtn: { - padding: 4, - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", + borderBottomColor: "#333", }, + closeBtn: { padding: 4 }, + headerTitle: { fontSize: 20, fontWeight: "700", color: "#fff" }, + infoBox: { flexDirection: "row", - alignItems: "flex-start", - backgroundColor: "#1e3a5f", padding: 16, - marginHorizontal: 20, - marginTop: 20, + margin: 20, + backgroundColor: "#333333", borderRadius: 12, gap: 12, }, - infoText: { - flex: 1, - fontSize: 13, - color: "#ffffff", - lineHeight: 18, - }, - content: { - flex: 1, - paddingHorizontal: 20, - paddingTop: 20, - }, - inputSection: { - marginBottom: 24, - }, - sectionTitle: { - fontSize: 16, - fontWeight: "700", - color: "#ffffff", - marginBottom: 12, - }, - inputContainer: { - flexDirection: "row", - gap: 12, - }, + infoText: { color: "#fff", fontSize: 13, lineHeight: 18 }, + + content: { flex: 1, paddingHorizontal: 20 }, + + inputSection: { marginBottom: 20 }, + sectionTitle: { color: "#fff", fontSize: 16, fontWeight: "700" }, + inputContainer: { flexDirection: "row", gap: 12 }, inputWrapper: { flex: 1, flexDirection: "row", @@ -405,122 +322,43 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: "rgba(255,255,255,0.1)", }, - inputIcon: { - marginRight: 12, - }, - input: { - flex: 1, - height: 50, - fontSize: 15, - color: "#ffffff", - }, - addBtn: { - width: 56, - height: 56, - borderRadius: 12, - overflow: "hidden", - }, - addBtnDisabled: { - opacity: 0.5, - }, + inputIcon: { marginRight: 12 }, + input: { flex: 1, height: 50, color: "#fff" }, + + addBtn: { width: 56, height: 56, borderRadius: 12, overflow: "hidden" }, + addBtnDisabled: { opacity: 0.5 }, addBtnGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - centerState: { flex: 1, - justifyContent: "center", - alignItems: "center", - marginTop: 40, - }, - emptyState: { alignItems: "center", - paddingVertical: 40, - paddingHorizontal: 20, - }, - emptyText: { - fontSize: 16, - color: "#999999", - textAlign: "center", - marginTop: 16, - marginBottom: 8, - }, - emptySubText: { - fontSize: 14, - color: "#666666", - textAlign: "center", - }, - listContent: { - paddingBottom: 20, - }, - foodList: { - gap: 12, - }, - foodItem: { - borderRadius: 12, - overflow: "hidden", + justifyContent: "center", }, + + foodItem: { marginBottom: 12 }, foodItemGradient: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - paddingVertical: 14, - paddingHorizontal: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", + padding: 16, borderRadius: 12, }, - foodItemLeft: { - flexDirection: "row", - alignItems: "center", - flex: 1, - gap: 12, - }, - foodItemIcon: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: "rgba(239,68,68,0.1)", - alignItems: "center", - justifyContent: "center", - }, - foodName: { - fontSize: 15, - fontWeight: "500", - color: "#ffffff", - flex: 1, - }, - removeBtn: { - padding: 4, - }, + foodName: { color: "#fff", fontSize: 15, flex: 1 }, + + emptyState: { alignItems: "center", marginTop: 40 }, + emptyText: { color: "#999", marginTop: 16 }, + + centerState: { marginTop: 40 }, + footer: { - position: "absolute", - bottom: 0, - left: 0, - right: 0, padding: 20, - backgroundColor: "#1a1a1a", borderTopWidth: 1, - borderTopColor: "#333333", - }, - saveBtn: { - borderRadius: 12, - overflow: "hidden", + borderTopColor: "#333", }, saveBtnGradient: { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", paddingVertical: 16, - gap: 8, - }, - saveBtnText: { - fontSize: 16, - fontWeight: "700", - color: "#111111", + borderRadius: 12, + alignItems: "center", }, + saveBtnText: { fontSize: 16, fontWeight: "700", color: "#111" }, }); export default DislikedFoodsModal; diff --git a/src/components/modals/PreferredFoodsModal.tsx b/src/components/modals/PreferredFoodsModal.tsx new file mode 100644 index 0000000..a70a39e --- /dev/null +++ b/src/components/modals/PreferredFoodsModal.tsx @@ -0,0 +1,534 @@ +// src/components/modals/PreferredFoodsModal.tsx +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + ScrollView, + TextInput, + Alert, + ActivityIndicator, + Keyboard, + KeyboardAvoidingView, + Platform, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { userPreferencesAPI } from "../../services/userPreferencesAPI"; +import type { PreferenceResponse } from "../../types"; + +interface PreferredFoodsModalProps { + visible: boolean; + userId: string; + onClose: () => void; + onUpdate: () => void; +} + +const PreferredFoodsModal: React.FC = ({ + visible, + userId, + onClose, + onUpdate, +}) => { + const [preferences, setPreferences] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [initialLoading, setInitialLoading] = useState(false); + const [processing, setProcessing] = useState(false); + + useEffect(() => { + if (visible && userId) { + loadPreferences(); + } + }, [visible, userId]); + + /** + * 선호 식단 목록 불러오기 + */ + const loadPreferences = async () => { + try { + setInitialLoading(true); + const data = await userPreferencesAPI.getPreferences(userId); + setPreferences(data); + } catch (error: any) { + console.error("선호 음식 로드 실패:", error); + Alert.alert("오류", "선호 식단을 불러오는데 실패했습니다."); + } finally { + setInitialLoading(false); + } + }; + + /** + * 음식 추가 핸들러 + * - 쉼표로 구분된 여러 음식을 한 번에 입력 가능 + */ + const handleAdd = async () => { + const trimmed = inputValue.trim(); + + if (!trimmed) { + Alert.alert("알림", "음식 이름을 입력해주세요."); + return; + } + + // 쉼표로 구분하여 배열로 변환 + const foodsToAdd = trimmed + .split(",") + .map((food) => food.trim()) + .filter((food) => food.length > 0); + + if (foodsToAdd.length === 0) { + Alert.alert("알림", "유효한 음식 이름을 입력해주세요."); + return; + } + + // 중복 검사 + const duplicates = foodsToAdd.filter((newFood) => + preferences.some((item) => { + const existingFoods = item.food_name.split(",").map((f) => f.trim()); + return existingFoods.includes(newFood); + }) + ); + + if (duplicates.length > 0) { + Alert.alert( + "알림", + `이미 목록에 있는 음식입니다: ${duplicates.join(", ")}` + ); + return; + } + + try { + setProcessing(true); + Keyboard.dismiss(); + + await userPreferencesAPI.addPreferences(userId, foodsToAdd); + + // 목록 새로고침 + await loadPreferences(); + setInputValue(""); + + Alert.alert("완료", `${foodsToAdd.join(", ")}이(가) 추가되었습니다.`); + + if (onUpdate) onUpdate(); + } catch (error: any) { + console.error("음식 추가 실패:", error); + Alert.alert("오류", "음식을 추가하지 못했습니다."); + } finally { + setProcessing(false); + } + }; + + /** + * 음식 삭제 핸들러 + */ + const handleRemove = (id: number, name: string) => { + Alert.alert("삭제", `"${name}"을(를) 선호 목록에서 삭제하시겠습니까?`, [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setProcessing(true); + + await userPreferencesAPI.deletePreference(id); + + // 로컬 상태 업데이트 + setPreferences((prev) => prev.filter((item) => item.id !== id)); + + Alert.alert("완료", "삭제되었습니다."); + + if (onUpdate) onUpdate(); + } catch (error: any) { + console.error("삭제 실패:", error); + Alert.alert("오류", "삭제에 실패했습니다."); + } finally { + setProcessing(false); + } + }, + }, + ]); + }; + + return ( + + {/* ✅ KeyboardAvoidingView 추가 */} + + + + + + {/* 헤더 */} + + + + + 선호 식단 설정 + + + + {/* 안내 메시지 */} + + + 쉼표(,)로 구분하여 여러 음식을 한 번에 추가할 수 있습니다. + {"\n"} + 예: 닭가슴살, 고구마, 브로콜리 + + + + + {/* 입력 필드 */} + + 음식 추가 + + + + + + + + {processing ? ( + + ) : ( + + )} + + + + + + {/* 선호 식단 목록 */} + + + 선호 식단 목록 ({preferences.length}) + + + {initialLoading ? ( + + + + ) : ( + + {preferences.length === 0 ? ( + + + + 아직 추가된 선호 식단이 없습니다. + + + 위 입력창에서 원하는 음식을 추가해보세요. + + + ) : ( + + {preferences.map((item) => ( + + + + + + + + + {item.food_name} + + + {item.source === "manual" + ? "직접 추가" + : "AI 추천"} + + + + + + handleRemove(item.id, item.food_name) + } + style={styles.removeBtn} + disabled={processing} + > + + + + + ))} + + )} + {/* ✅ 하단 여백 추가 */} + + + )} + + + + {/* 닫기 버튼 - ✅ absolute 제거 */} + + + + 닫기 + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + }, + container: { + flex: 1, + marginTop: 50, + backgroundColor: "#1a1a1a", + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 20, + paddingHorizontal: 20, + borderBottomWidth: 1, + borderBottomColor: "#333333", + }, + closeBtn: { + padding: 4, + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + }, + infoBox: { + flexDirection: "row", + alignItems: "flex-start", + backgroundColor: "#333333", + padding: 16, + marginHorizontal: 20, + marginTop: 20, + borderRadius: 12, + gap: 12, + }, + infoText: { + flex: 1, + fontSize: 13, + color: "#ffffff", + lineHeight: 18, + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + inputSection: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 16, + fontWeight: "700", + color: "#ffffff", + marginBottom: 12, + }, + inputContainer: { + flexDirection: "row", + gap: 12, + }, + inputWrapper: { + flex: 1, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + inputIcon: { + marginRight: 12, + }, + input: { + flex: 1, + height: 50, + fontSize: 15, + color: "#ffffff", + }, + addBtn: { + width: 56, + height: 56, + borderRadius: 12, + overflow: "hidden", + }, + addBtnDisabled: { + opacity: 0.5, + }, + addBtnGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + centerState: { + flex: 1, + justifyContent: "center", + alignItems: "center", + marginTop: 40, + }, + emptyState: { + alignItems: "center", + paddingVertical: 40, + paddingHorizontal: 20, + }, + emptyText: { + fontSize: 16, + color: "#999999", + textAlign: "center", + marginTop: 16, + marginBottom: 8, + }, + emptySubText: { + fontSize: 14, + color: "#666666", + textAlign: "center", + }, + listContent: { + paddingBottom: 20, + }, + foodList: { + gap: 12, + }, + foodItem: { + borderRadius: 12, + overflow: "hidden", + }, + foodItemGradient: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 14, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 12, + }, + foodItemLeft: { + flexDirection: "row", + alignItems: "center", + flex: 1, + gap: 12, + }, + foodItemIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(186, 228, 0, 0.2)", + alignItems: "center", + justifyContent: "center", + }, + foodItemTextContainer: { + flex: 1, + }, + foodName: { + fontSize: 15, + fontWeight: "600", + color: "#ffffff", + marginBottom: 2, + }, + foodSource: { + fontSize: 12, + color: "#6b7280", + }, + removeBtn: { + padding: 4, + }, + footer: { + // ✅ position: "absolute" 제거됨 + padding: 20, + backgroundColor: "#1a1a1a", + borderTopWidth: 1, + borderTopColor: "#333333", + }, + saveBtn: { + borderRadius: 12, + overflow: "hidden", + }, + saveBtnGradient: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + paddingVertical: 16, + gap: 8, + }, + saveBtnText: { + fontSize: 16, + fontWeight: "700", + color: "#111111", + }, +}); + +export default PreferredFoodsModal; diff --git a/src/screens/diet/MealRecommendScreen.tsx b/src/screens/diet/MealRecommendScreen.tsx index 2a6cc47..b2f1fed 100644 --- a/src/screens/diet/MealRecommendScreen.tsx +++ b/src/screens/diet/MealRecommendScreen.tsx @@ -1,2549 +1,2758 @@ -// src/screens/diet/MealRecommendScreen.tsx -import React, { useState, useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Alert, - ActivityIndicator, - Modal, - Animated, - Easing, - Dimensions, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useNavigation } from "@react-navigation/native"; -// 🔹 authAPI import 추가 -import { - recommendedMealAPI, - userPreferencesAPI, - authAPI, -} from "../../services"; -import { LinearGradient } from "expo-linear-gradient"; -import DislikedFoodsModal from "../../components/modals/DislikedFoodsModal"; -import type { ExclusionResponse } from "../../types"; - -const { width } = Dimensions.get("window"); - -const LOADING_MESSAGES = [ - "입력하신 정보를 수집하는 중...", - "회원님께 최적화된 식단을 준비하는 중...", - "영양소 균형을 계산하는 중...", - "맛있는 조합을 찾는 중...", - "거의 다 됐어요! 조금만 기다려주세요...", -]; - -// ✅ 로딩 오버레이 컴포넌트 -const LoadingOverlay = ({ - visible, - messages = LOADING_MESSAGES, - onCancel, -}: { - visible: boolean; - 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; - const scaleAnim = useRef(new Animated.Value(1)).current; - - useEffect(() => { - if (!visible) { - setCurrentMessageIndex(0); - fadeAnim.setValue(1); - return; - } - - Animated.loop( - Animated.timing(spinAnim, { - toValue: 1, - duration: 2000, - useNativeDriver: true, - easing: Easing.linear, - }) - ).start(); - - Animated.loop( - Animated.sequence([ - Animated.timing(scaleAnim, { - toValue: 1.1, - duration: 1000, - useNativeDriver: true, - easing: Easing.inOut(Easing.ease), - }), - Animated.timing(scaleAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - easing: Easing.inOut(Easing.ease), - }), - ]) - ).start(); - - fadeAnim.setValue(1); - - const interval = setInterval(() => { - Animated.timing(fadeAnim, { - toValue: 0, - duration: 500, - useNativeDriver: true, - easing: Easing.out(Easing.ease), - }).start(() => { - setCurrentMessageIndex((prev) => (prev + 1) % messages.length); - Animated.timing(fadeAnim, { - toValue: 1, - duration: 800, - useNativeDriver: true, - easing: Easing.in(Easing.ease), - }).start(); - }); - }, 3500); - - return () => clearInterval(interval); - }, [visible, messages.length]); - - const spin = spinAnim.interpolate({ - inputRange: [0, 1], - outputRange: ["0deg", "360deg"], - }); - - return ( - - - - - - - - - - - - - - {messages && messages.length > 0 - ? messages[currentMessageIndex] - : "로딩 중..."} - - - - {onCancel && ( - - - 요청 취소하기 - - - )} - - - - ); -}; - -const loadingStyles = StyleSheet.create({ - overlay: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - container: { - alignItems: "center", - justifyContent: "center", - width: "100%", - paddingHorizontal: 20, - }, - spinnerContainer: { - marginBottom: 40, - }, - spinnerOuter: { - width: 100, - height: 100, - borderRadius: 50, - borderWidth: 4, - borderColor: "rgba(227, 255, 124, 0.2)", - alignItems: "center", - justifyContent: "center", - }, - spinnerInner: { - width: 80, - height: 80, - borderRadius: 40, - borderWidth: 4, - borderColor: "#e3ff7c", - borderTopColor: "transparent", - borderRightColor: "transparent", - }, - textContainer: { - width: "100%", - alignItems: "center", - justifyContent: "center", - minHeight: 70, - }, - message: { - fontSize: 18, - fontWeight: "600", - color: "#ffffff", - textAlign: "center", - lineHeight: 28, - letterSpacing: 0.5, - }, - cancelButton: { - marginTop: 50, - borderRadius: 30, - overflow: "hidden", - }, - cancelButtonGradient: { - paddingVertical: 14, - paddingHorizontal: 28, - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.2)", - borderRadius: 30, - }, - cancelText: { - color: "#ffffff", - fontSize: 15, - fontWeight: "600", - letterSpacing: 0.5, - }, -}); - -// ✅ 끼니 선택 모달 -const MealsSelectionModal = ({ - visible, - currentMeals, - onSelect, - onClose, -}: { - visible: boolean; - currentMeals: number; - onSelect: (meals: number) => void; - onClose: () => void; -}) => { - return ( - - - - - - - - 끼니 수 선택 - - - - 하루에 몇 끼를 드시나요? - - - - {[1, 2, 3].map((num) => ( - { - onSelect(num); - onClose(); - }} - activeOpacity={0.8} - > - - - - {currentMeals === num ? ( - - ) : ( - - )} - - - - {num}끼 - - - {num === 1 - ? "하루 1끼" - : num === 2 - ? "아침 + 점심 또는 점심 + 저녁" - : "아침 + 점심 + 저녁"} - - - - - - ))} - - - - 취소 - - - - - - - ); -}; - -const mealsModalStyles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.8)", - justifyContent: "center", - alignItems: "center", - }, - container: { - width: "85%", - maxWidth: 400, - }, - content: { - borderRadius: 24, - padding: 28, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - }, - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 12, - marginBottom: 12, - }, - title: { - fontSize: 24, - fontWeight: "800", - color: "#ffffff", - letterSpacing: 0.5, - }, - subtitle: { - fontSize: 15, - color: "#9ca3af", - textAlign: "center", - marginBottom: 28, - letterSpacing: 0.3, - }, - optionsContainer: { - gap: 12, - marginBottom: 24, - }, - optionButton: { - borderRadius: 16, - overflow: "hidden", - }, - optionGradient: { - borderRadius: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - optionContent: { - flexDirection: "row", - alignItems: "center", - padding: 18, - gap: 16, - }, - optionIconContainer: { - width: 48, - height: 48, - alignItems: "center", - justifyContent: "center", - }, - optionTextContainer: { - flex: 1, - }, - optionNumber: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - marginBottom: 4, - letterSpacing: 0.3, - }, - optionNumberActive: { - color: "#111827", - }, - optionDesc: { - fontSize: 13, - color: "#9ca3af", - letterSpacing: 0.2, - }, - optionDescActive: { - color: "rgba(17,24,39,0.7)", - }, - closeButton: { - paddingVertical: 14, - alignItems: "center", - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 12, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - closeButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#9ca3af", - letterSpacing: 0.3, - }, -}); - -const transformTempMealToUI = (tempDay: any, dayIndex: number) => { - let breakfast, lunch, dinner; - - if (tempDay.meals && tempDay.meals.length > 0) { - breakfast = tempDay.meals.find((m: any) => m.mealType === "BREAKFAST"); - lunch = tempDay.meals.find((m: any) => m.mealType === "LUNCH"); - dinner = tempDay.meals.find((m: any) => m.mealType === "DINNER"); - - if (!breakfast && !lunch && !dinner) { - const snacks = tempDay.meals.filter((m: any) => m.mealType === "SNACK"); - - if (snacks.length >= 1) { - breakfast = { - ...snacks[0], - mealType: "BREAKFAST", - mealTypeName: "아침", - }; - } - if (snacks.length >= 2) { - lunch = { - ...snacks[1], - mealType: "LUNCH", - mealTypeName: "점심", - }; - } - if (snacks.length >= 3) { - dinner = { - ...snacks[2], - mealType: "DINNER", - mealTypeName: "저녁", - }; - } - } - } - - const totalCalories = - (breakfast?.totalCalories || 0) + - (lunch?.totalCalories || 0) + - (dinner?.totalCalories || 0); - - const totalCarbs = - (breakfast?.totalCarbs || 0) + - (lunch?.totalCarbs || 0) + - (dinner?.totalCarbs || 0); - - const totalProtein = - (breakfast?.totalProtein || 0) + - (lunch?.totalProtein || 0) + - (dinner?.totalProtein || 0); - - const totalFat = - (breakfast?.totalFat || 0) + - (lunch?.totalFat || 0) + - (dinner?.totalFat || 0); - - const planDate = new Date(); - planDate.setDate(planDate.getDate() + (dayIndex - 1)); - - return { - day: dayIndex, - date: `${planDate.getMonth() + 1}/${planDate.getDate()}`, - fullDate: planDate.toLocaleDateString("ko-KR", { - month: "long", - day: "numeric", - weekday: "short", - }), - planId: null, - planName: "AI 추천 식단", - description: "맞춤형 7일 식단", - recommendationReason: "영양소 균형을 고려한 식단", - totalCalories: Math.round(totalCalories), - carbs: Math.round(totalCarbs), - protein: Math.round(totalProtein), - fat: Math.round(totalFat), - isSaved: false, - breakfast: { - meals: - breakfast?.foods.map((f: any) => ({ - name: f.foodName, - calories: Math.round(f.calories), - carbs: Math.round(f.carbs), - protein: Math.round(f.protein), - fat: Math.round(f.fat), - liked: false, - })) || [], - calories: Math.round(breakfast?.totalCalories || 0), - carbs: Math.round(breakfast?.totalCarbs || 0), - protein: Math.round(breakfast?.totalProtein || 0), - fat: Math.round(breakfast?.totalFat || 0), - }, - lunch: { - meals: - lunch?.foods.map((f: any) => ({ - name: f.foodName, - calories: Math.round(f.calories), - carbs: Math.round(f.carbs), - protein: Math.round(f.protein), - fat: Math.round(f.fat), - liked: false, - })) || [], - calories: Math.round(lunch?.totalCalories || 0), - carbs: Math.round(lunch?.totalCarbs || 0), - protein: Math.round(lunch?.totalProtein || 0), - fat: Math.round(lunch?.totalFat || 0), - }, - dinner: { - meals: - dinner?.foods.map((f: any) => ({ - name: f.foodName, - calories: Math.round(f.calories), - carbs: Math.round(f.carbs), - protein: Math.round(f.protein), - fat: Math.round(f.fat), - liked: false, - })) || [], - calories: Math.round(dinner?.totalCalories || 0), - carbs: Math.round(dinner?.totalCarbs || 0), - protein: Math.round(dinner?.totalProtein || 0), - fat: Math.round(dinner?.totalFat || 0), - }, - }; -}; - -const MealRecommendScreen = () => { - const navigation = useNavigation(); - const [userId, setUserId] = useState(""); - - 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< - ExclusionResponse[] - >([]); - - const [loading, setLoading] = useState(false); - const [savedMeals, setSavedMeals] = useState([]); - const [currentPlanId, setCurrentPlanId] = useState(null); - - const [mealsPerDay, setMealsPerDay] = useState(3); - const [showMealsModal, setShowMealsModal] = useState(false); - - const fadeAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }).start(); - }, [screen]); - - // 🔹 비선호 식단 목록 로드 함수 - userId를 파라미터로 받음 - const loadExclusions = async (currentUserId: string) => { - try { - const data = await userPreferencesAPI.getExclusions(currentUserId); - setExcludedIngredients(data); - console.log("✅ 비선호 음식 로드 완료:", data.length, "개"); - } catch (error) { - console.error("비선호 음식 로드 실패:", error); - } - }; - - // 🔹 유저 데이터 로드 함수 - const loadUserData = async () => { - try { - // 1. 프로필 조회하여 userId 획득 - const profile = await authAPI.getProfile(); - const currentUserId = profile.userId; - setUserId(currentUserId); - console.log("✅ 유저 ID 로드 완료:", currentUserId); - - // 2. 획득한 userId로 비선호 식단 조회 - await loadExclusions(currentUserId); - - // 3. 저장된 식단 불러오기 - await loadSavedMeals(); - } catch (error) { - console.error("사용자 데이터 로드 실패:", error); - } - }; - - // 🔹 useEffect에서 loadUserData 호출 - useEffect(() => { - const init = async () => { - console.log("========== 초기 데이터 로드 =========="); - await loadUserData(); - }; - init(); - }, []); - - const loadSavedMeals = async () => { - try { - const localStored = await AsyncStorage.getItem("savedMealPlans"); - const localMeals = localStored ? JSON.parse(localStored) : []; - - const serverPlans = await recommendedMealAPI.getSavedMealPlans(); - - const bundleMap = new Map(); - - serverPlans.forEach((plan) => { - if (!bundleMap.has(plan.bundleId)) { - bundleMap.set(plan.bundleId, { - id: plan.bundleId, - bundleId: plan.bundleId, - planName: plan.planName, - description: "", - totalCalories: 0, - createdAt: plan.createdAt, - mealCount: 0, - isServerMeal: true, - }); - } - - const bundle = bundleMap.get(plan.bundleId)!; - bundle.mealCount++; - bundle.totalCalories += plan.totalCalories; - }); - - const serverBundles = Array.from(bundleMap.values()).map((bundle) => ({ - ...bundle, - totalCalories: Math.round( - bundle.totalCalories / (bundle.mealCount || 1) - ), - description: `${bundle.mealCount}일 식단`, - })); - - const allMeals = [...localMeals, ...serverBundles]; - setSavedMeals(allMeals); - } catch (error) { - console.error("저장된 식단 불러오기 실패:", error); - } - }; - - const handleCancelLoading = () => { - Alert.alert("요청 취소", "식단 추천 요청을 취소하시겠습니까?", [ - { text: "계속 기다리기", style: "cancel" }, - { - text: "취소", - style: "destructive", - onPress: () => { - console.log("⚠️ 사용자가 로딩을 취소함"); - setLoading(false); - }, - }, - ]); - }; - - // ✅ 7일 식단 추천 받기 - const handleGetRecommendation = async () => { - setLoading(true); - - try { - console.log("🍽️ 임시 식단 생성 시작"); - const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); - - if (!tempMeals || tempMeals.length === 0) { - throw new Error("식단 생성에 실패했습니다."); - } - - console.log(`✅ ${tempMeals.length}일치 임시 식단 생성 완료`); - - const weekData = tempMeals.map((tempDay, index) => { - const transformed = transformTempMealToUI(tempDay, index + 1); - return transformed; - }); - - setWeeklyMeals(weekData); - setCurrentPlanId(null); - setScreen("meals"); - setCurrentDay(0); - - Alert.alert( - "성공", - `${weekData.length}일치 맞춤 식단이 생성되었습니다! 🎉` - ); - } catch (error: any) { - console.error("❌ 식단 추천 실패:", error); - Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); - } finally { - setLoading(false); - } - }; - - // ✅ 1일 식단 추천 받기 - const handleGetSingleDayRecommendation = async () => { - setLoading(true); - - try { - console.log("🍽️ 1일 식단 생성 시작"); - const tempMeals = await recommendedMealAPI.getDailyMealPlan(mealsPerDay); - - if (!tempMeals || tempMeals.length === 0) { - throw new Error("식단 생성에 실패했습니다."); - } - - console.log("✅ 1일 식단 생성 완료"); - - const weekData = tempMeals.map((tempDay, index) => - transformTempMealToUI(tempDay, index + 1) - ); - - setWeeklyMeals(weekData); - setCurrentPlanId(null); - setScreen("meals"); - setCurrentDay(0); - - Alert.alert("성공", "오늘의 맞춤 식단이 생성되었습니다! 🎉"); - } catch (error: any) { - console.error("❌ 1일 식단 추천 실패:", error); - let errorMessage = error.message || "식단을 불러오는데 실패했습니다."; - if (error.status === 500) { - errorMessage = - "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; - } - Alert.alert("오류", errorMessage); - } finally { - setLoading(false); - } - }; - - // ✅ 좋아요 토글 함수 - const handleToggleLike = (mealType: string, mealIndex: number) => { - setWeeklyMeals((prev) => { - const updated = [...prev]; - const dayMeals = { ...updated[currentDay] }; - const mealArray = [...dayMeals[mealType].meals]; - - mealArray[mealIndex] = { - ...mealArray[mealIndex], - liked: !mealArray[mealIndex].liked, - }; - - dayMeals[mealType] = { - ...dayMeals[mealType], - meals: mealArray, - }; - - updated[currentDay] = dayMeals; - return updated; - }); - }; - - const handleDeleteMeal = (mealType: string, mealIndex: number) => { - const currentMeal = weeklyMeals[currentDay]; - const mealData = currentMeal[mealType]; - - if (mealData.meals.length === 1) { - Alert.alert( - "음식 삭제", - `${ - mealType === "breakfast" - ? "아침" - : mealType === "lunch" - ? "점심" - : "저녁" - }의 마지막 음식입니다. 삭제하시겠습니까?`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: () => deleteFood(mealType, mealIndex), - }, - ] - ); - } else { - deleteFood(mealType, mealIndex); - } - }; - - const deleteFood = (mealType: string, mealIndex: number) => { - setWeeklyMeals((prev) => { - const updated = [...prev]; - const dayMeals = { ...updated[currentDay] }; - const mealArray = [...dayMeals[mealType].meals]; - - const removedMeal = mealArray[mealIndex]; - mealArray.splice(mealIndex, 1); - - const newMealCalories = Math.max( - 0, - dayMeals[mealType].calories - removedMeal.calories - ); - const newMealCarbs = Math.max( - 0, - dayMeals[mealType].carbs - removedMeal.carbs - ); - const newMealProtein = Math.max( - 0, - dayMeals[mealType].protein - removedMeal.protein - ); - const newMealFat = Math.max(0, dayMeals[mealType].fat - removedMeal.fat); - - dayMeals[mealType] = { - meals: mealArray, - calories: newMealCalories, - carbs: newMealCarbs, - protein: newMealProtein, - fat: newMealFat, - }; - - const newTotalCalories = - dayMeals.breakfast.calories + - dayMeals.lunch.calories + - dayMeals.dinner.calories; - const newTotalCarbs = - dayMeals.breakfast.carbs + dayMeals.lunch.carbs + dayMeals.dinner.carbs; - const newTotalProtein = - dayMeals.breakfast.protein + - dayMeals.lunch.protein + - dayMeals.dinner.protein; - const newTotalFat = - dayMeals.breakfast.fat + dayMeals.lunch.fat + dayMeals.dinner.fat; - - dayMeals.totalCalories = newTotalCalories; - dayMeals.carbs = newTotalCarbs; - dayMeals.protein = newTotalProtein; - dayMeals.fat = newTotalFat; - - updated[currentDay] = dayMeals; - - return updated; - }); - }; - - const handleSaveMealPlan = async () => { - try { - setLoading(true); - console.log("💾 식단 저장 시작"); - - await recommendedMealAPI.saveTempMealPlan(); - - Alert.alert("저장 완료", "식단이 성공적으로 저장되었습니다!", [ - { - text: "확인", - onPress: () => { - console.log("✅ 식단 저장 완료 - 현재 화면 유지"); - }, - }, - ]); - } catch (error: any) { - console.error("❌ 식단 저장 실패:", error); - Alert.alert("저장 실패", error.message || "식단 저장에 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleDeleteSavedMeal = async (meal: any) => { - const isLocalMeal = - typeof meal.id === "string" && meal.id.startsWith("local_"); - - Alert.alert( - "삭제", - `이 ${isLocalMeal ? "로컬" : "서버"} 식단을 삭제하시겠습니까?${ - !isLocalMeal && meal.mealCount ? `\n(${meal.mealCount}일치)` : "" - }`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - if (isLocalMeal) { - const stored = await AsyncStorage.getItem("savedMealPlans"); - const existingMeals = stored ? JSON.parse(stored) : []; - const updatedMeals = existingMeals.filter( - (m: any) => m.id !== meal.id - ); - await AsyncStorage.setItem( - "savedMealPlans", - JSON.stringify(updatedMeals) - ); - } else { - await recommendedMealAPI.deleteBundle(meal.bundleId || meal.id); - } - - await loadSavedMeals(); - Alert.alert("성공", "식단이 삭제되었습니다."); - } catch (error: any) { - console.error("식단 삭제 실패:", error); - Alert.alert("오류", error.message || "식단 삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ] - ); - }; - - const currentMeal = weeklyMeals[currentDay]; - - if (screen === "welcome") { - return ( - - - - - - - setShowDislikedModal(false)} - onUpdate={() => loadExclusions(userId)} - /> - - { - setMealsPerDay(num); - console.log(`✅ 끼니 수 변경: ${num}끼`); - }} - onClose={() => setShowMealsModal(false)} - /> - - - navigation.goBack()} - style={styles.backButton} - > - - - - - - - - - - - 🥗 - - - 맞춤 식단 추천 - - AI가 당신만을 위한{"\n"}완벽한 식단을 설계합니다 - - - - - - - - - 7일 추천 식단 받기 - - - - - - - - - 1일 추천 식단 받기 - - - - - setShowDislikedModal(true)} - activeOpacity={0.8} - > - - - - 금지 식재료 관리 - {excludedIngredients.length > 0 && - ` (${excludedIngredients.length})`} - - - - - setShowMealsModal(true)} - activeOpacity={0.8} - > - - - - 끼니 수정하기 ({mealsPerDay}끼) - - - - - - {excludedIngredients.length > 0 && ( - - - 제외된 식재료 - - {excludedIngredients.map((item) => ( - - - {item.food_name} - - - ))} - - - - )} - - {savedMeals.length > 0 && ( - - 저장된 식단 - {savedMeals.map((meal) => { - const isLocalMeal = - typeof meal.id === "string" && meal.id.startsWith("local_"); - - return ( - { - if (isLocalMeal) { - const weekly = (meal.meals || []).map( - (m: any, idx: number) => ({ - day: idx + 1, - date: "", - fullDate: "", - planId: null, - planName: meal.planName || "AI 식단", - description: meal.description || "", - recommendationReason: "", - totalCalories: m.totalCalories || 0, - carbs: m.carbs || 0, - protein: m.protein || 0, - fat: m.fat || 0, - isSaved: true, - breakfast: m.breakfast, - lunch: m.lunch, - dinner: m.dinner, - }) - ); - - setWeeklyMeals(weekly); - setCurrentPlanId(null); - setScreen("meals"); - setCurrentDay(0); - } else { - navigation.navigate("MealRecommendHistory" as never); - } - }} - activeOpacity={0.8} - > - - - - - - {meal.planName || "식단 계획"} - - {isLocalMeal && ( - - - - - 로컬 - - - - )} - - - {meal.totalCalories || 0} kcal ·{" "} - {meal.description || ""} - - - { - e.stopPropagation(); - handleDeleteSavedMeal(meal); - }} - style={styles.deleteButton} - > - - - - - - ); - })} - - )} - - - - ); - } - - return ( - - - - - - setScreen("welcome")} - style={styles.backButton} - > - - - - - 식단 추천 - - - - - {currentMeal && ( - - - - {currentMeal.fullDate} - - - )} - - - {weeklyMeals.map((_, index) => ( - setCurrentDay(index)} - activeOpacity={0.8} - > - {currentDay === index ? ( - - {index + 1}일차 - - ) : ( - - {index + 1}일차 - - )} - - ))} - - - {currentMeal && ( - - - - - - 총 칼로리 - - - {currentMeal.totalCalories} - - kcal - - - - - - - - - - - - - - - - - - - 탄수화물 - - {currentMeal.carbs} - g - - - - - - - - - - 단백질 - - {currentMeal.protein} - g - - - - - - - - - - 지방 - - {currentMeal.fat} - g - - - - - - - {/* 아침 */} - - - - - - - 🌅 - - - - 아침 - 07:00 - 09:00 - - - - - {currentMeal.breakfast.calories} - - kcal - - - - - - - - 탄 {currentMeal.breakfast.carbs}g - - - - - - 단 {currentMeal.breakfast.protein}g - - - - - - 지 {currentMeal.breakfast.fat}g - - - - - - {currentMeal.breakfast.meals.length === 0 ? ( - - 식사 없음 - - ) : ( - currentMeal.breakfast.meals.map( - (meal: any, index: number) => ( - - - - - - {meal.name} - - - ({meal.calories}kcal) - - - - - - handleToggleLike("breakfast", index) - } - activeOpacity={0.7} - > - - - - - handleDeleteMeal("breakfast", index) - } - activeOpacity={0.7} - > - - - - - - - ) - ) - )} - - - - - {/* 점심 */} - - - - - - - ☀️ - - - - 점심 - 12:00 - 14:00 - - - - - {currentMeal.lunch.calories} - - kcal - - - - - - - - 탄 {currentMeal.lunch.carbs}g - - - - - - 단 {currentMeal.lunch.protein}g - - - - - - 지 {currentMeal.lunch.fat}g - - - - - - {currentMeal.lunch.meals.length === 0 ? ( - - 식사 없음 - - ) : ( - currentMeal.lunch.meals.map( - (meal: any, index: number) => ( - - - - - - {meal.name} - - - ({meal.calories}kcal) - - - - - - handleToggleLike("lunch", index) - } - activeOpacity={0.7} - > - - - - - handleDeleteMeal("lunch", index) - } - activeOpacity={0.7} - > - - - - - - - ) - ) - )} - - - - - {/* 저녁 */} - - - - - - - 🌙 - - - - 저녁 - 18:00 - 20:00 - - - - - {currentMeal.dinner.calories} - - kcal - - - - - - - - 탄 {currentMeal.dinner.carbs}g - - - - - - 단 {currentMeal.dinner.protein}g - - - - - - 지 {currentMeal.dinner.fat}g - - - - - - {currentMeal.dinner.meals.length === 0 ? ( - - 식사 없음 - - ) : ( - currentMeal.dinner.meals.map( - (meal: any, index: number) => ( - - - - - - {meal.name} - - - ({meal.calories}kcal) - - - - - - handleToggleLike("dinner", index) - } - activeOpacity={0.7} - > - - - - - handleDeleteMeal("dinner", index) - } - activeOpacity={0.7} - > - - - - - - - ) - ) - )} - - - - - - - - {loading ? ( - - ) : ( - <> - - 식단 저장하기 - - )} - - - - - - {loading ? ( - - ) : ( - <> - - - 다시 추천받기 - - - )} - - - - - )} - - - setCurrentDay(Math.max(0, currentDay - 1))} - disabled={currentDay === 0} - activeOpacity={0.8} - > - - - - - - - - {currentDay + 1} / {weeklyMeals.length} - - - - - setCurrentDay(Math.min(weeklyMeals.length - 1, currentDay + 1)) - } - disabled={currentDay === weeklyMeals.length - 1} - activeOpacity={0.8} - > - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - safeArea: { - flex: 1, - }, - contentWrapper: { - flex: 1, - }, - contentContainer: { - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: 40, - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 20, - }, - backButton: { - width: 40, - height: 40, - }, - iconButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: "rgba(255,255,255,0.1)", - alignItems: "center", - justifyContent: "center", - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - letterSpacing: 0.5, - }, - welcomeHeader: { - alignItems: "center", - marginTop: 40, - marginBottom: 60, - }, - welcomeIconContainer: { - marginBottom: 24, - }, - welcomeIcon: { - width: 100, - height: 100, - borderRadius: 50, - alignItems: "center", - justifyContent: "center", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.4, - shadowRadius: 20, - elevation: 10, - }, - welcomeEmoji: { - fontSize: 48, - }, - welcomeTitle: { - fontSize: 32, - fontWeight: "bold", - color: "#ffffff", - marginBottom: 12, - textAlign: "center", - letterSpacing: 0.5, - }, - welcomeSubtitle: { - fontSize: 16, - color: "#9ca3af", - textAlign: "center", - lineHeight: 24, - letterSpacing: 0.3, - }, - mainActions: { - gap: 16, - marginBottom: 30, - }, - primaryButton: { - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - primaryButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 18, - paddingHorizontal: 24, - }, - primaryButtonText: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, - secondaryButton: { - borderRadius: 16, - overflow: "hidden", - }, - secondaryButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 16, - paddingHorizontal: 24, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.2)", - borderRadius: 16, - }, - secondaryButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - letterSpacing: 0.3, - }, - excludedPreview: { - marginTop: 20, - marginBottom: 20, - }, - glassCard: { - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 16, - padding: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - excludedPreviewLabel: { - fontSize: 15, - color: "#9ca3af", - marginBottom: 16, - fontWeight: "600", - letterSpacing: 0.3, - }, - tagList: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - tag: { - borderRadius: 12, - overflow: "hidden", - }, - tagGradient: { - paddingVertical: 8, - paddingHorizontal: 14, - borderRadius: 12, - }, - tagText: { - fontSize: 14, - color: "#ffffff", - fontWeight: "500", - }, - savedMealsSection: { - marginTop: 30, - }, - sectionTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - marginBottom: 16, - letterSpacing: 0.5, - }, - savedMealItem: { - marginBottom: 12, - borderRadius: 16, - overflow: "hidden", - }, - savedMealGradient: { - borderRadius: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - savedMealContent: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - padding: 16, - }, - savedMealLeft: { - flex: 1, - }, - savedMealTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginBottom: 6, - }, - savedMealTitle: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - letterSpacing: 0.3, - }, - localBadge: { - borderRadius: 8, - overflow: "hidden", - }, - localBadgeGradient: { - flexDirection: "row", - alignItems: "center", - gap: 4, - paddingHorizontal: 8, - paddingVertical: 3, - }, - localBadgeText: { - fontSize: 11, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, - savedMealInfo: { - fontSize: 14, - color: "#6b7280", - letterSpacing: 0.2, - }, - deleteButton: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: "rgba(239,68,68,0.1)", - alignItems: "center", - justifyContent: "center", - }, - mealDateContainer: { - paddingHorizontal: 20, - paddingTop: 16, - paddingBottom: 16, - alignItems: "center", - }, - dateBadge: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 16, - paddingVertical: 8, - borderRadius: 20, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - }, - mealDate: { - fontSize: 14, - color: "#E3FF7C", - fontWeight: "600", - letterSpacing: 0.3, - }, - dayTabs: { - marginVertical: 16, - }, - dayTabsContent: { - paddingHorizontal: 20, - gap: 10, - }, - dayTabContainer: { - borderRadius: 24, - }, - dayTab: { - backgroundColor: "rgba(255,255,255,0.05)", - paddingVertical: 10, - paddingHorizontal: 20, - borderRadius: 24, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - dayTabActive: { - paddingVertical: 10, - paddingHorizontal: 20, - borderRadius: 24, - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 4, - }, - dayTabText: { - fontSize: 14, - color: "#9ca3af", - fontWeight: "500", - letterSpacing: 0.3, - }, - dayTabTextActive: { - fontSize: 14, - color: "#111827", - fontWeight: "700", - letterSpacing: 0.3, - }, - mealContent: { - paddingHorizontal: 20, - paddingBottom: 20, - }, - nutritionCardContainer: { - marginBottom: 20, - }, - nutritionCard: { - borderRadius: 20, - padding: 24, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - caloriesHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 20, - }, - caloriesTotalLabel: { - fontSize: 14, - color: "#9ca3af", - marginBottom: 6, - fontWeight: "500", - letterSpacing: 0.5, - }, - caloriesTotalRow: { - flexDirection: "row", - alignItems: "baseline", - }, - caloriesTotal: { - fontSize: 42, - fontWeight: "800", - color: "#ffffff", - letterSpacing: -0.5, - }, - caloriesTotalUnit: { - fontSize: 20, - color: "#6b7280", - marginLeft: 4, - fontWeight: "600", - }, - caloriesIcon: { - width: 56, - height: 56, - borderRadius: 28, - overflow: "hidden", - }, - caloriesIconGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - nutritionDivider: { - height: 1, - backgroundColor: "rgba(255,255,255,0.1)", - marginBottom: 20, - }, - nutritionInfo: { - flexDirection: "row", - justifyContent: "space-between", - gap: 12, - }, - nutritionItem: { - flex: 1, - alignItems: "center", - }, - nutritionIconContainer: { - marginBottom: 10, - }, - nutritionIcon: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: "center", - justifyContent: "center", - }, - nutritionLabel: { - color: "#9ca3af", - fontSize: 13, - marginBottom: 6, - fontWeight: "500", - letterSpacing: 0.3, - }, - nutritionValue: { - fontWeight: "700", - fontSize: 20, - color: "#ffffff", - letterSpacing: 0.3, - }, - nutritionUnit: { - fontSize: 14, - color: "#6b7280", - fontWeight: "600", - }, - mealCardContainer: { - marginBottom: 16, - }, - mealCard: { - borderRadius: 18, - padding: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - mealCardHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 16, - }, - mealTitleRow: { - flexDirection: "row", - alignItems: "center", - gap: 12, - }, - mealIconContainer: { - width: 48, - height: 48, - borderRadius: 24, - overflow: "hidden", - }, - mealIcon: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - mealEmoji: { - fontSize: 24, - }, - mealTitle: { - fontWeight: "700", - fontSize: 18, - color: "#ffffff", - letterSpacing: 0.3, - }, - mealTime: { - fontSize: 12, - color: "#6b7280", - marginTop: 2, - fontWeight: "500", - }, - mealCaloriesContainer: { - flexDirection: "row", - alignItems: "baseline", - }, - mealCalories: { - fontWeight: "700", - fontSize: 20, - color: "#ffffff", - letterSpacing: 0.3, - }, - mealCaloriesUnit: { - fontSize: 14, - color: "#6b7280", - marginLeft: 2, - fontWeight: "600", - }, - mealNutritionMini: { - flexDirection: "row", - gap: 16, - marginBottom: 16, - paddingVertical: 8, - }, - miniNutrient: { - flexDirection: "row", - alignItems: "center", - gap: 6, - }, - mealNutritionText: { - fontSize: 13, - color: "#9ca3af", - fontWeight: "600", - letterSpacing: 0.2, - }, - mealTags: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - mealTag: { - borderRadius: 14, - overflow: "hidden", - }, - mealTagGradient: { - paddingVertical: 10, - paddingHorizontal: 14, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - borderRadius: 14, - }, - mealTagContent: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - width: "100%", - }, - mealTagTextContainer: { - flexDirection: "row", - alignItems: "center", - gap: 6, - flex: 1, - }, - mealTagActions: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginLeft: 8, - }, - mealLikeBtn: { - width: 28, - height: 28, - alignItems: "center", - justifyContent: "center", - }, - mealName: { - color: "#ffffff", - fontSize: 14, - fontWeight: "600", - letterSpacing: 0.2, - }, - mealCal: { - color: "#9ca3af", - fontSize: 12, - fontWeight: "500", - }, - mealDeleteBtn: { - width: 28, - height: 28, - alignItems: "center", - justifyContent: "center", - }, - emptyMealState: { - paddingVertical: 20, - paddingHorizontal: 16, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(255,255,255,0.03)", - borderRadius: 12, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.05)", - borderStyle: "dashed", - width: "100%", - }, - emptyMealText: { - fontSize: 14, - color: "#6b7280", - fontWeight: "500", - fontStyle: "italic", - }, - actionButtons: { - marginTop: 24, - gap: 12, - }, - saveButton: { - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - saveButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 18, - }, - saveButtonText: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, - refreshButton: { - borderRadius: 16, - overflow: "hidden", - }, - refreshButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.2)", - borderRadius: 16, - }, - refreshButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - letterSpacing: 0.3, - }, - navigation: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginTop: 20, - marginBottom: 40, - marginHorizontal: 20, - }, - navBtn: { - width: 50, - height: 50, - borderRadius: 25, - overflow: "hidden", - }, - navBtnGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 25, - }, - dayIndicator: { - paddingHorizontal: 20, - paddingVertical: 8, - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - dayIndicatorText: { - fontSize: 14, - color: "#9ca3af", - fontWeight: "600", - letterSpacing: 0.5, - }, -}); - -export default MealRecommendScreen; +// src/screens/diet/MealRecommendScreen.tsx +import React, { useState, useEffect, useRef } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + Modal, + Animated, + Easing, + Dimensions, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useNavigation } from "@react-navigation/native"; +import { recommendedMealAPI, authAPI } from "../../services"; +import { LinearGradient } from "expo-linear-gradient"; +import DislikedFoodsModal from "../../components/modals/DislikedFoodsModal"; +import type { + ExclusionResponse, + PreferenceResponse, + PreferenceDeleteResponse, +} from "../../types"; +import { userPreferencesAPI } from "../../services/userPreferencesAPI"; +import PreferredFoodsModal from "../../components/modals/PreferredFoodsModal"; + +const { width } = Dimensions.get("window"); + +const LOADING_MESSAGES = [ + "입력하신 정보를 수집하는 중...", + "회원님께 최적화된 식단을 준비하는 중...", + "영양소 균형을 계산하는 중...", + "맛있는 조합을 찾는 중...", + "거의 다 됐어요! 조금만 기다려주세요...", +]; + +// ✅ 로딩 오버레이 컴포넌트 +const LoadingOverlay = ({ + visible, + messages = LOADING_MESSAGES, + onCancel, +}: { + visible: boolean; + 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; + const scaleAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + if (!visible) { + setCurrentMessageIndex(0); + fadeAnim.setValue(1); + return; + } + + Animated.loop( + Animated.timing(spinAnim, { + toValue: 1, + duration: 2000, + useNativeDriver: true, + easing: Easing.linear, + }) + ).start(); + + Animated.loop( + Animated.sequence([ + Animated.timing(scaleAnim, { + toValue: 1.1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(scaleAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ).start(); + + fadeAnim.setValue(1); + + const interval = setInterval(() => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + easing: Easing.out(Easing.ease), + }).start(() => { + setCurrentMessageIndex((prev) => (prev + 1) % messages.length); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + easing: Easing.in(Easing.ease), + }).start(); + }); + }, 3500); + + return () => clearInterval(interval); + }, [visible, messages.length]); + + const spin = spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + + return ( + + + + + + + + + + + + + + {messages && messages.length > 0 + ? messages[currentMessageIndex] + : "로딩 중..."} + + + + {onCancel && ( + + + 요청 취소하기 + + + )} + + + + ); +}; + +const loadingStyles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + container: { + alignItems: "center", + justifyContent: "center", + width: "100%", + paddingHorizontal: 20, + }, + spinnerContainer: { + marginBottom: 40, + }, + spinnerOuter: { + width: 100, + height: 100, + borderRadius: 50, + borderWidth: 4, + borderColor: "rgba(227, 255, 124, 0.2)", + alignItems: "center", + justifyContent: "center", + }, + spinnerInner: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 4, + borderColor: "#e3ff7c", + borderTopColor: "transparent", + borderRightColor: "transparent", + }, + textContainer: { + width: "100%", + alignItems: "center", + justifyContent: "center", + minHeight: 70, + }, + message: { + fontSize: 18, + fontWeight: "600", + color: "#ffffff", + textAlign: "center", + lineHeight: 28, + letterSpacing: 0.5, + }, + cancelButton: { + marginTop: 50, + borderRadius: 30, + overflow: "hidden", + }, + cancelButtonGradient: { + paddingVertical: 14, + paddingHorizontal: 28, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.2)", + borderRadius: 30, + }, + cancelText: { + color: "#ffffff", + fontSize: 15, + fontWeight: "600", + letterSpacing: 0.5, + }, +}); + +// ✅ 끼니 선택 모달 +const MealsSelectionModal = ({ + visible, + currentMeals, + onSelect, + onClose, +}: { + visible: boolean; + currentMeals: number; + onSelect: (meals: number) => void; + onClose: () => void; +}) => { + return ( + + + + + + + + 끼니 수 선택 + + + + 하루에 몇 끼를 드시나요? + + + + {[1, 2, 3].map((num) => ( + { + onSelect(num); + onClose(); + }} + activeOpacity={0.8} + > + + + + {currentMeals === num ? ( + + ) : ( + + )} + + + + {num}끼 + + + {num === 1 + ? "하루 1끼" + : num === 2 + ? "아침 + 점심 또는 점심 + 저녁" + : "아침 + 점심 + 저녁"} + + + + + + ))} + + + + 취소 + + + + + + + ); +}; + +const mealsModalStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.8)", + justifyContent: "center", + alignItems: "center", + }, + container: { + width: "85%", + maxWidth: 400, + }, + content: { + borderRadius: 24, + padding: 28, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 12, + marginBottom: 12, + }, + title: { + fontSize: 24, + fontWeight: "800", + color: "#ffffff", + letterSpacing: 0.5, + }, + subtitle: { + fontSize: 15, + color: "#9ca3af", + textAlign: "center", + marginBottom: 28, + letterSpacing: 0.3, + }, + optionsContainer: { + gap: 12, + marginBottom: 24, + }, + optionButton: { + borderRadius: 16, + overflow: "hidden", + }, + optionGradient: { + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + optionContent: { + flexDirection: "row", + alignItems: "center", + padding: 18, + gap: 16, + }, + optionIconContainer: { + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + }, + optionTextContainer: { + flex: 1, + }, + optionNumber: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + letterSpacing: 0.3, + }, + optionNumberActive: { + color: "#111827", + }, + optionDesc: { + fontSize: 13, + color: "#9ca3af", + letterSpacing: 0.2, + }, + optionDescActive: { + color: "rgba(17,24,39,0.7)", + }, + closeButton: { + paddingVertical: 14, + alignItems: "center", + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + closeButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#9ca3af", + letterSpacing: 0.3, + }, +}); + +const transformTempMealToUI = (tempDay: any, dayIndex: number) => { + let breakfast, lunch, dinner; + + if (tempDay.meals && tempDay.meals.length > 0) { + breakfast = tempDay.meals.find((m: any) => m.mealType === "BREAKFAST"); + lunch = tempDay.meals.find((m: any) => m.mealType === "LUNCH"); + dinner = tempDay.meals.find((m: any) => m.mealType === "DINNER"); + + if (!breakfast && !lunch && !dinner) { + const snacks = tempDay.meals.filter((m: any) => m.mealType === "SNACK"); + + if (snacks.length >= 1) { + breakfast = { + ...snacks[0], + mealType: "BREAKFAST", + mealTypeName: "아침", + }; + } + if (snacks.length >= 2) { + lunch = { + ...snacks[1], + mealType: "LUNCH", + mealTypeName: "점심", + }; + } + if (snacks.length >= 3) { + dinner = { + ...snacks[2], + mealType: "DINNER", + mealTypeName: "저녁", + }; + } + } + } + + const totalCalories = + (breakfast?.totalCalories || 0) + + (lunch?.totalCalories || 0) + + (dinner?.totalCalories || 0); + + const totalCarbs = + (breakfast?.totalCarbs || 0) + + (lunch?.totalCarbs || 0) + + (dinner?.totalCarbs || 0); + + const totalProtein = + (breakfast?.totalProtein || 0) + + (lunch?.totalProtein || 0) + + (dinner?.totalProtein || 0); + + const totalFat = + (breakfast?.totalFat || 0) + + (lunch?.totalFat || 0) + + (dinner?.totalFat || 0); + + const planDate = new Date(); + planDate.setDate(planDate.getDate() + (dayIndex - 1)); + + return { + day: dayIndex, + date: `${planDate.getMonth() + 1}/${planDate.getDate()}`, + fullDate: planDate.toLocaleDateString("ko-KR", { + month: "long", + day: "numeric", + weekday: "short", + }), + planId: null, + planName: "AI 추천 식단", + description: "맞춤형 7일 식단", + recommendationReason: "영양소 균형을 고려한 식단", + totalCalories: Math.round(totalCalories), + carbs: Math.round(totalCarbs), + protein: Math.round(totalProtein), + fat: Math.round(totalFat), + isSaved: false, + breakfast: { + meals: + breakfast?.foods.map((f: any) => ({ + name: f.foodName, + calories: Math.round(f.calories), + carbs: Math.round(f.carbs), + protein: Math.round(f.protein), + fat: Math.round(f.fat), + liked: false, + })) || [], + calories: Math.round(breakfast?.totalCalories || 0), + carbs: Math.round(breakfast?.totalCarbs || 0), + protein: Math.round(breakfast?.totalProtein || 0), + fat: Math.round(breakfast?.totalFat || 0), + }, + lunch: { + meals: + lunch?.foods.map((f: any) => ({ + name: f.foodName, + calories: Math.round(f.calories), + carbs: Math.round(f.carbs), + protein: Math.round(f.protein), + fat: Math.round(f.fat), + liked: false, + })) || [], + calories: Math.round(lunch?.totalCalories || 0), + carbs: Math.round(lunch?.totalCarbs || 0), + protein: Math.round(lunch?.totalProtein || 0), + fat: Math.round(lunch?.totalFat || 0), + }, + dinner: { + meals: + dinner?.foods.map((f: any) => ({ + name: f.foodName, + calories: Math.round(f.calories), + carbs: Math.round(f.carbs), + protein: Math.round(f.protein), + fat: Math.round(f.fat), + liked: false, + })) || [], + calories: Math.round(dinner?.totalCalories || 0), + carbs: Math.round(dinner?.totalCarbs || 0), + protein: Math.round(dinner?.totalProtein || 0), + fat: Math.round(dinner?.totalFat || 0), + }, + }; +}; + +const MealRecommendScreen = () => { + const navigation = useNavigation(); + const [userId, setUserId] = useState(""); + + const [screen, setScreen] = useState<"welcome" | "meals">("welcome"); + const [showDislikedModal, setShowDislikedModal] = useState(false); + const [showPreferredModal, setShowPreferredModal] = useState(false); + const [weeklyMeals, setWeeklyMeals] = useState([]); + const [currentDay, setCurrentDay] = useState(0); + + const [excludedIngredients, setExcludedIngredients] = useState< + ExclusionResponse[] + >([]); + const [preferredIngredients, setPreferredIngredients] = useState< + PreferenceResponse[] + >([]); + const [recommendationType, setRecommendationType] = useState< + "weekly" | "daily" + >("weekly"); + const [loading, setLoading] = useState(false); + const [savedMeals, setSavedMeals] = useState([]); + const [currentPlanId, setCurrentPlanId] = useState(null); + + const [mealsPerDay, setMealsPerDay] = useState(3); + const [showMealsModal, setShowMealsModal] = useState(false); + + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }).start(); + }, [screen]); + + // 비선호 식단 목록 로드 함수 - userId를 파라미터로 받음 + const loadExclusions = async (currentUserId: string) => { + try { + const data = await userPreferencesAPI.getExclusions(currentUserId); + setExcludedIngredients(data); + console.log("✅ 비선호 음식 로드 완료:", data.length, "개"); + } catch (error) { + console.error("비선호 음식 로드 실패:", error); + } + }; + + //선호 식단 목록 로드 함수 추가 + const loadPreferences = async (currentUserId: string) => { + try { + const data = await userPreferencesAPI.getPreferences(currentUserId); + setPreferredIngredients(data); + console.log("✅ 선호 음식 로드 완료:", data.length, "개"); + } catch (error) { + console.error("선호 음식 로드 실패:", error); + } + }; + + // 🔹 유저 데이터 로드 함수 + const loadUserData = async () => { + try { + // 1. 프로필 조회하여 userId 획득 + const profile = await authAPI.getProfile(); + const currentUserId = profile.userId; + setUserId(currentUserId); + console.log("✅ 유저 ID 로드 완료:", currentUserId); + + // 2. 획득한 userId로 비선호 , 선호 식단 조회 + await loadExclusions(currentUserId); + await loadPreferences(currentUserId); + // 3. 저장된 식단 불러오기 + await loadSavedMeals(); + } catch (error) { + console.error("사용자 데이터 로드 실패:", error); + } + }; + + // 🔹 useEffect에서 loadUserData 호출 + useEffect(() => { + const init = async () => { + console.log("========== 초기 데이터 로드 =========="); + await loadUserData(); + }; + init(); + }, []); + + const loadSavedMeals = async () => { + try { + const localStored = await AsyncStorage.getItem("savedMealPlans"); + const localMeals = localStored ? JSON.parse(localStored) : []; + + const serverPlans = await recommendedMealAPI.getSavedMealPlans(); + + const bundleMap = new Map(); + + serverPlans.forEach((plan) => { + if (!bundleMap.has(plan.bundleId)) { + bundleMap.set(plan.bundleId, { + id: plan.bundleId, + bundleId: plan.bundleId, + planName: plan.planName, + description: "", + totalCalories: 0, + createdAt: plan.createdAt, + mealCount: 0, + isServerMeal: true, + }); + } + + const bundle = bundleMap.get(plan.bundleId)!; + bundle.mealCount++; + bundle.totalCalories += plan.totalCalories; + }); + + const serverBundles = Array.from(bundleMap.values()).map((bundle) => ({ + ...bundle, + totalCalories: Math.round( + bundle.totalCalories / (bundle.mealCount || 1) + ), + description: `${bundle.mealCount}일 식단`, + })); + + const allMeals = [...localMeals, ...serverBundles]; + setSavedMeals(allMeals); + } catch (error) { + console.error("저장된 식단 불러오기 실패:", error); + } + }; + + const handleCancelLoading = () => { + Alert.alert("요청 취소", "식단 추천 요청을 취소하시겠습니까?", [ + { text: "계속 기다리기", style: "cancel" }, + { + text: "취소", + style: "destructive", + onPress: () => { + console.log("⚠️ 사용자가 로딩을 취소함"); + setLoading(false); + }, + }, + ]); + }; + + // ✅ 7일 식단 추천 받기 + const handleGetRecommendation = async () => { + setLoading(true); + setRecommendationType("weekly"); + + try { + console.log("🍽️ 임시 식단 생성 시작"); + const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); + + if (!tempMeals || tempMeals.length === 0) { + throw new Error("식단 생성에 실패했습니다."); + } + + console.log(`✅ ${tempMeals.length}일치 임시 식단 생성 완료`); + + const weekData = tempMeals.map((tempDay, index) => { + const transformed = transformTempMealToUI(tempDay, index + 1); + return transformed; + }); + + setWeeklyMeals(weekData); + setCurrentPlanId(null); + setScreen("meals"); + setCurrentDay(0); + + Alert.alert( + "성공", + `${weekData.length}일치 맞춤 식단이 생성되었습니다! 🎉` + ); + } catch (error: any) { + console.error("❌ 식단 추천 실패:", error); + Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // ✅ 1일 식단 추천 받기 + const handleGetSingleDayRecommendation = async () => { + setLoading(true); + setRecommendationType("daily"); + try { + console.log("🍽️ 1일 식단 생성 시작"); + const tempMeals = await recommendedMealAPI.getDailyMealPlan(mealsPerDay); + + if (!tempMeals || tempMeals.length === 0) { + throw new Error("식단 생성에 실패했습니다."); + } + + console.log("✅ 1일 식단 생성 완료"); + + const weekData = tempMeals.map((tempDay, index) => + transformTempMealToUI(tempDay, index + 1) + ); + + setWeeklyMeals(weekData); + setCurrentPlanId(null); + setScreen("meals"); + setCurrentDay(0); + + Alert.alert("성공", "오늘의 맞춤 식단이 생성되었습니다! 🎉"); + } catch (error: any) { + console.error("❌ 1일 식단 추천 실패:", error); + let errorMessage = error.message || "식단을 불러오는데 실패했습니다."; + if (error.status === 500) { + errorMessage = + "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; + } + Alert.alert("오류", errorMessage); + } finally { + setLoading(false); + } + }; + const handleRefresh = () => { + if (recommendationType === "daily") { + handleGetSingleDayRecommendation(); + } else { + handleGetRecommendation(); + } + }; + // ✅ 좋아요 토글 함수 + const handleToggleLike = (mealType: string, mealIndex: number) => { + setWeeklyMeals((prev) => { + const updated = [...prev]; + const dayMeals = { ...updated[currentDay] }; + const mealArray = [...dayMeals[mealType].meals]; + + mealArray[mealIndex] = { + ...mealArray[mealIndex], + liked: !mealArray[mealIndex].liked, + }; + + dayMeals[mealType] = { + ...dayMeals[mealType], + meals: mealArray, + }; + + updated[currentDay] = dayMeals; + return updated; + }); + }; + const collectLikedFoods = (): string[] => { + const likedFoods: string[] = []; + + weeklyMeals.forEach((day) => { + // 아침 + day.breakfast.meals.forEach((meal: any) => { + if (meal.liked) { + likedFoods.push(meal.name); + } + }); + + // 점심 + day.lunch.meals.forEach((meal: any) => { + if (meal.liked) { + likedFoods.push(meal.name); + } + }); + + // 저녁 + day.dinner.meals.forEach((meal: any) => { + if (meal.liked) { + likedFoods.push(meal.name); + } + }); + }); + + // 중복 제거 + return Array.from(new Set(likedFoods)); + }; + + // 선호 음식 저장 함수 + const handleSavePreferences = async () => { + const likedFoods = collectLikedFoods(); + + if (likedFoods.length === 0) { + Alert.alert( + "알림", + "좋아요한 음식이 없습니다.\n하트를 눌러 좋아하는 음식을 선택해주세요." + ); + return; + } + + try { + setLoading(true); + console.log("💚 선호 음식 저장 시작:", likedFoods); + + await userPreferencesAPI.addPreferences(userId, likedFoods); + + Alert.alert( + "저장 완료", + `${ + likedFoods.length + }개의 음식이 선호 식단에 추가되었습니다! 💚\n\n추가된 음식:\n${likedFoods.join( + ", " + )}` + ); + } catch (error: any) { + console.error("❌ 선호 음식 저장 실패:", error); + Alert.alert( + "저장 실패", + error.message || "선호 음식 저장에 실패했습니다." + ); + } finally { + setLoading(false); + } + }; + + // 식단 저장 시 선호 음식도 함께 저장하는 통합 함수 + const handleSaveMealPlanWithPreferences = async () => { + const likedFoods = collectLikedFoods(); + + Alert.alert( + "식단 저장", + likedFoods.length > 0 + ? `식단과 함께 ${ + likedFoods.length + }개의 선호 음식도 저장하시겠습니까?\n\n선호 음식: ${likedFoods.join( + ", " + )}` + : "식단을 저장하시겠습니까?", + [ + { text: "취소", style: "cancel" }, + { + text: "저장", + onPress: async () => { + try { + setLoading(true); + + // 1. 식단 저장 + await recommendedMealAPI.saveTempMealPlan(); + + // 2. 선호 음식 저장 (있는 경우) + if (likedFoods.length > 0) { + await userPreferencesAPI.addPreferences(userId, likedFoods); + } + + Alert.alert( + "저장 완료", + likedFoods.length > 0 + ? `식단과 ${likedFoods.length}개의 선호 음식이 저장되었습니다! 🎉` + : "식단이 저장되었습니다! 🎉" + ); + } catch (error: any) { + console.error("❌ 저장 실패:", error); + Alert.alert("저장 실패", error.message || "저장에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const handleDeleteMeal = (mealType: string, mealIndex: number) => { + const currentMeal = weeklyMeals[currentDay]; + const mealData = currentMeal[mealType]; + + if (mealData.meals.length === 1) { + Alert.alert( + "음식 삭제", + `${ + mealType === "breakfast" + ? "아침" + : mealType === "lunch" + ? "점심" + : "저녁" + }의 마지막 음식입니다. 삭제하시겠습니까?`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: () => deleteFood(mealType, mealIndex), + }, + ] + ); + } else { + deleteFood(mealType, mealIndex); + } + }; + + const deleteFood = (mealType: string, mealIndex: number) => { + setWeeklyMeals((prev) => { + const updated = [...prev]; + const dayMeals = { ...updated[currentDay] }; + const mealArray = [...dayMeals[mealType].meals]; + + const removedMeal = mealArray[mealIndex]; + mealArray.splice(mealIndex, 1); + + const newMealCalories = Math.max( + 0, + dayMeals[mealType].calories - removedMeal.calories + ); + const newMealCarbs = Math.max( + 0, + dayMeals[mealType].carbs - removedMeal.carbs + ); + const newMealProtein = Math.max( + 0, + dayMeals[mealType].protein - removedMeal.protein + ); + const newMealFat = Math.max(0, dayMeals[mealType].fat - removedMeal.fat); + + dayMeals[mealType] = { + meals: mealArray, + calories: newMealCalories, + carbs: newMealCarbs, + protein: newMealProtein, + fat: newMealFat, + }; + + const newTotalCalories = + dayMeals.breakfast.calories + + dayMeals.lunch.calories + + dayMeals.dinner.calories; + const newTotalCarbs = + dayMeals.breakfast.carbs + dayMeals.lunch.carbs + dayMeals.dinner.carbs; + const newTotalProtein = + dayMeals.breakfast.protein + + dayMeals.lunch.protein + + dayMeals.dinner.protein; + const newTotalFat = + dayMeals.breakfast.fat + dayMeals.lunch.fat + dayMeals.dinner.fat; + + dayMeals.totalCalories = newTotalCalories; + dayMeals.carbs = newTotalCarbs; + dayMeals.protein = newTotalProtein; + dayMeals.fat = newTotalFat; + + updated[currentDay] = dayMeals; + + return updated; + }); + }; + + const handleSaveMealPlan = async () => { + try { + setLoading(true); + console.log("💾 식단 저장 시작"); + + await recommendedMealAPI.saveTempMealPlan(); + + Alert.alert("저장 완료", "식단이 성공적으로 저장되었습니다!", [ + { + text: "확인", + onPress: () => { + console.log("✅ 식단 저장 완료 - 현재 화면 유지"); + }, + }, + ]); + } catch (error: any) { + console.error("❌ 식단 저장 실패:", error); + Alert.alert("저장 실패", error.message || "식단 저장에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleDeleteSavedMeal = async (meal: any) => { + const isLocalMeal = + typeof meal.id === "string" && meal.id.startsWith("local_"); + + Alert.alert( + "삭제", + `이 ${isLocalMeal ? "로컬" : "서버"} 식단을 삭제하시겠습니까?${ + !isLocalMeal && meal.mealCount ? `\n(${meal.mealCount}일치)` : "" + }`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + if (isLocalMeal) { + const stored = await AsyncStorage.getItem("savedMealPlans"); + const existingMeals = stored ? JSON.parse(stored) : []; + const updatedMeals = existingMeals.filter( + (m: any) => m.id !== meal.id + ); + await AsyncStorage.setItem( + "savedMealPlans", + JSON.stringify(updatedMeals) + ); + } else { + await recommendedMealAPI.deleteBundle(meal.bundleId || meal.id); + } + + await loadSavedMeals(); + Alert.alert("성공", "식단이 삭제되었습니다."); + } catch (error: any) { + console.error("식단 삭제 실패:", error); + Alert.alert("오류", error.message || "식단 삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const currentMeal = weeklyMeals[currentDay]; + + if (screen === "welcome") { + return ( + + + + + + + setShowDislikedModal(false)} + onUpdate={() => loadExclusions(userId)} + /> + + setShowPreferredModal(false)} + onUpdate={() => loadPreferences(userId)} + /> + { + setMealsPerDay(num); + console.log(`✅ 끼니 수 변경: ${num}끼`); + }} + onClose={() => setShowMealsModal(false)} + /> + + + navigation.goBack()} + style={styles.backButton} + > + + + + + + + + + + + 🥗 + + + 맞춤 식단 추천 + + AI가 당신만을 위한{"\n"}완벽한 식단을 설계합니다 + + + + + + + + + 7일 추천 식단 받기 + + + + + + + + + 1일 추천 식단 받기 + + + + + setShowDislikedModal(true)} + activeOpacity={0.8} + > + + + + 금지 식재료 관리 + + + + + setShowPreferredModal(true)} + activeOpacity={0.8} + > + + + + 선호 식재료 관리 + + + + + setShowMealsModal(true)} + activeOpacity={0.8} + > + + + + 끼니 수정하기 ({mealsPerDay}끼) + + + + + + {excludedIngredients.length > 0 && ( + + + 제외된 식재료 + + {excludedIngredients.map((item) => ( + + + {item.food_name} + + + ))} + + + + )} + {/* 선호 식재료 UI */} + {preferredIngredients.length > 0 && ( + + + + + 선호 식재료 + + + + + {preferredIngredients.map((item) => ( + + + + {item.food_name} + + + + ))} + + + + )} + {savedMeals.length > 0 && ( + + 저장된 식단 + {savedMeals.map((meal) => { + const isLocalMeal = + typeof meal.id === "string" && meal.id.startsWith("local_"); + + return ( + { + if (isLocalMeal) { + const weekly = (meal.meals || []).map( + (m: any, idx: number) => ({ + day: idx + 1, + date: "", + fullDate: "", + planId: null, + planName: meal.planName || "AI 식단", + description: meal.description || "", + recommendationReason: "", + totalCalories: m.totalCalories || 0, + carbs: m.carbs || 0, + protein: m.protein || 0, + fat: m.fat || 0, + isSaved: true, + breakfast: m.breakfast, + lunch: m.lunch, + dinner: m.dinner, + }) + ); + + setWeeklyMeals(weekly); + setCurrentPlanId(null); + setScreen("meals"); + setCurrentDay(0); + } else { + navigation.navigate("MealRecommendHistory" as never); + } + }} + activeOpacity={0.8} + > + + + + + + {meal.planName || "식단 계획"} + + {isLocalMeal && ( + + + + + 로컬 + + + + )} + + + {meal.totalCalories || 0} kcal ·{" "} + {meal.description || ""} + + + { + e.stopPropagation(); + handleDeleteSavedMeal(meal); + }} + style={styles.deleteButton} + > + + + + + + ); + })} + + )} + + + + ); + } + + return ( + + + + + + + + setScreen("welcome")} + style={styles.backButton} + > + + + + + 식단 추천 + + + + + {currentMeal && ( + + + + {currentMeal.fullDate} + + + )} + + + {weeklyMeals.map((_, index) => ( + setCurrentDay(index)} + activeOpacity={0.8} + > + {currentDay === index ? ( + + {index + 1}일차 + + ) : ( + + {index + 1}일차 + + )} + + ))} + + + {currentMeal && ( + + + + + + 총 칼로리 + + + {currentMeal.totalCalories} + + kcal + + + + + + + + + + + + + + + + + + + 탄수화물 + + {currentMeal.carbs} + g + + + + + + + + + + 단백질 + + {currentMeal.protein} + g + + + + + + + + + + 지방 + + {currentMeal.fat} + g + + + + + + + {/* 아침 */} + + + + + + + 🌅 + + + + 아침 + 07:00 - 09:00 + + + + + {currentMeal.breakfast.calories} + + kcal + + + + + + + + 탄 {currentMeal.breakfast.carbs}g + + + + + + 단 {currentMeal.breakfast.protein}g + + + + + + 지 {currentMeal.breakfast.fat}g + + + + + + {currentMeal.breakfast.meals.length === 0 ? ( + + 식사 없음 + + ) : ( + currentMeal.breakfast.meals.map( + (meal: any, index: number) => ( + + + + + + {meal.name} + + + ({meal.calories}kcal) + + + + + + handleToggleLike("breakfast", index) + } + activeOpacity={0.7} + > + + + + + handleDeleteMeal("breakfast", index) + } + activeOpacity={0.7} + > + + + + + + + ) + ) + )} + + + + + {/* 점심 */} + + + + + + + ☀️ + + + + 점심 + 12:00 - 14:00 + + + + + {currentMeal.lunch.calories} + + kcal + + + + + + + + 탄 {currentMeal.lunch.carbs}g + + + + + + 단 {currentMeal.lunch.protein}g + + + + + + 지 {currentMeal.lunch.fat}g + + + + + + {currentMeal.lunch.meals.length === 0 ? ( + + 식사 없음 + + ) : ( + currentMeal.lunch.meals.map( + (meal: any, index: number) => ( + + + + + + {meal.name} + + + ({meal.calories}kcal) + + + + + + handleToggleLike("lunch", index) + } + activeOpacity={0.7} + > + + + + + handleDeleteMeal("lunch", index) + } + activeOpacity={0.7} + > + + + + + + + ) + ) + )} + + + + + {/* 저녁 */} + + + + + + + 🌙 + + + + 저녁 + 18:00 - 20:00 + + + + + {currentMeal.dinner.calories} + + kcal + + + + + + + + 탄 {currentMeal.dinner.carbs}g + + + + + + 단 {currentMeal.dinner.protein}g + + + + + + 지 {currentMeal.dinner.fat}g + + + + + + {currentMeal.dinner.meals.length === 0 ? ( + + 식사 없음 + + ) : ( + currentMeal.dinner.meals.map( + (meal: any, index: number) => ( + + + + + + {meal.name} + + + ({meal.calories}kcal) + + + + + + handleToggleLike("dinner", index) + } + activeOpacity={0.7} + > + + + + + handleDeleteMeal("dinner", index) + } + activeOpacity={0.7} + > + + + + + + + ) + ) + )} + + + + + + {/* 식단 저장 버튼 (선호 음식 포함) */} + + + {loading ? ( + + ) : ( + <> + + 식단 저장하기 + + )} + + + + + + {loading ? ( + + ) : ( + <> + + + 다시 추천받기 + + + )} + + + + + )} + + + setCurrentDay(Math.max(0, currentDay - 1))} + disabled={currentDay === 0} + activeOpacity={0.8} + > + + + + + + + + {currentDay + 1} / {weeklyMeals.length} + + + + + setCurrentDay(Math.min(weeklyMeals.length - 1, currentDay + 1)) + } + disabled={currentDay === weeklyMeals.length - 1} + activeOpacity={0.8} + > + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + contentWrapper: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 40, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 20, + }, + backButton: { + width: 40, + height: 40, + }, + iconButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255,255,255,0.1)", + alignItems: "center", + justifyContent: "center", + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + letterSpacing: 0.5, + }, + welcomeHeader: { + alignItems: "center", + marginTop: 40, + marginBottom: 60, + }, + welcomeIconContainer: { + marginBottom: 24, + }, + welcomeIcon: { + width: 100, + height: 100, + borderRadius: 50, + alignItems: "center", + justifyContent: "center", + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.4, + shadowRadius: 20, + elevation: 10, + }, + welcomeEmoji: { + fontSize: 48, + }, + welcomeTitle: { + fontSize: 32, + fontWeight: "bold", + color: "#ffffff", + marginBottom: 12, + textAlign: "center", + letterSpacing: 0.5, + }, + welcomeSubtitle: { + fontSize: 16, + color: "#9ca3af", + textAlign: "center", + lineHeight: 24, + letterSpacing: 0.3, + }, + mainActions: { + gap: 16, + marginBottom: 30, + }, + primaryButton: { + borderRadius: 16, + overflow: "hidden", + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + primaryButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 18, + paddingHorizontal: 24, + }, + primaryButtonText: { + fontSize: 17, + fontWeight: "700", + color: "#111827", + letterSpacing: 0.5, + }, + secondaryButton: { + borderRadius: 16, + overflow: "hidden", + }, + secondaryButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + paddingHorizontal: 24, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.2)", + borderRadius: 16, + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + }, + excludedPreview: { + marginTop: 20, + marginBottom: 20, + }, + glassCard: { + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + excludedPreviewLabel: { + fontSize: 15, + color: "#9ca3af", + marginBottom: 16, + fontWeight: "600", + letterSpacing: 0.3, + }, + tagList: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + tag: { + borderRadius: 12, + overflow: "hidden", + }, + tagGradient: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 12, + }, + tagText: { + fontSize: 14, + color: "#ffffff", + fontWeight: "500", + }, + savedMealsSection: { + marginTop: 30, + }, + sectionTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 16, + letterSpacing: 0.5, + }, + savedMealItem: { + marginBottom: 12, + borderRadius: 16, + overflow: "hidden", + }, + savedMealGradient: { + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + savedMealContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + padding: 16, + }, + savedMealLeft: { + flex: 1, + }, + savedMealTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginBottom: 6, + }, + savedMealTitle: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + }, + localBadge: { + borderRadius: 8, + overflow: "hidden", + }, + localBadgeGradient: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 8, + paddingVertical: 3, + }, + localBadgeText: { + fontSize: 11, + fontWeight: "700", + color: "#111827", + letterSpacing: 0.5, + }, + savedMealInfo: { + fontSize: 14, + color: "#6b7280", + letterSpacing: 0.2, + }, + deleteButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(239,68,68,0.1)", + alignItems: "center", + justifyContent: "center", + }, + mealDateContainer: { + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 16, + alignItems: "center", + }, + dateBadge: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + }, + mealDate: { + fontSize: 14, + color: "#E3FF7C", + fontWeight: "600", + letterSpacing: 0.3, + }, + dayTabs: { + marginVertical: 16, + }, + dayTabsContent: { + paddingHorizontal: 20, + gap: 10, + }, + dayTabContainer: { + borderRadius: 24, + }, + dayTab: { + backgroundColor: "rgba(255,255,255,0.05)", + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 24, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + dayTabActive: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 24, + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 4, + }, + dayTabText: { + fontSize: 14, + color: "#9ca3af", + fontWeight: "500", + letterSpacing: 0.3, + }, + dayTabTextActive: { + fontSize: 14, + color: "#111827", + fontWeight: "700", + letterSpacing: 0.3, + }, + mealContent: { + paddingHorizontal: 20, + paddingBottom: 20, + }, + nutritionCardContainer: { + marginBottom: 20, + }, + nutritionCard: { + borderRadius: 20, + padding: 24, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + caloriesHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + caloriesTotalLabel: { + fontSize: 14, + color: "#9ca3af", + marginBottom: 6, + fontWeight: "500", + letterSpacing: 0.5, + }, + caloriesTotalRow: { + flexDirection: "row", + alignItems: "baseline", + }, + caloriesTotal: { + fontSize: 42, + fontWeight: "800", + color: "#ffffff", + letterSpacing: -0.5, + }, + caloriesTotalUnit: { + fontSize: 20, + color: "#6b7280", + marginLeft: 4, + fontWeight: "600", + }, + caloriesIcon: { + width: 56, + height: 56, + borderRadius: 28, + overflow: "hidden", + }, + caloriesIconGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + nutritionDivider: { + height: 1, + backgroundColor: "rgba(255,255,255,0.1)", + marginBottom: 20, + }, + nutritionInfo: { + flexDirection: "row", + justifyContent: "space-between", + gap: 12, + }, + nutritionItem: { + flex: 1, + alignItems: "center", + }, + nutritionIconContainer: { + marginBottom: 10, + }, + nutritionIcon: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: "center", + justifyContent: "center", + }, + nutritionLabel: { + color: "#9ca3af", + fontSize: 13, + marginBottom: 6, + fontWeight: "500", + letterSpacing: 0.3, + }, + nutritionValue: { + fontWeight: "700", + fontSize: 20, + color: "#ffffff", + letterSpacing: 0.3, + }, + nutritionUnit: { + fontSize: 14, + color: "#6b7280", + fontWeight: "600", + }, + mealCardContainer: { + marginBottom: 16, + }, + mealCard: { + borderRadius: 18, + padding: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + mealCardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + mealTitleRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + mealIconContainer: { + width: 48, + height: 48, + borderRadius: 24, + overflow: "hidden", + }, + mealIcon: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + mealEmoji: { + fontSize: 24, + }, + mealTitle: { + fontWeight: "700", + fontSize: 18, + color: "#ffffff", + letterSpacing: 0.3, + }, + mealTime: { + fontSize: 12, + color: "#6b7280", + marginTop: 2, + fontWeight: "500", + }, + mealCaloriesContainer: { + flexDirection: "row", + alignItems: "baseline", + }, + mealCalories: { + fontWeight: "700", + fontSize: 20, + color: "#ffffff", + letterSpacing: 0.3, + }, + mealCaloriesUnit: { + fontSize: 14, + color: "#6b7280", + marginLeft: 2, + fontWeight: "600", + }, + mealNutritionMini: { + flexDirection: "row", + gap: 16, + marginBottom: 16, + paddingVertical: 8, + }, + miniNutrient: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + mealNutritionText: { + fontSize: 13, + color: "#9ca3af", + fontWeight: "600", + letterSpacing: 0.2, + }, + mealTags: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + mealTag: { + borderRadius: 14, + overflow: "hidden", + }, + mealTagGradient: { + paddingVertical: 10, + paddingHorizontal: 14, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + borderRadius: 14, + }, + mealTagContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + }, + mealTagTextContainer: { + flexDirection: "row", + alignItems: "center", + gap: 6, + flex: 1, + }, + mealTagActions: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginLeft: 8, + }, + mealLikeBtn: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, + mealName: { + color: "#ffffff", + fontSize: 14, + fontWeight: "600", + letterSpacing: 0.2, + }, + mealCal: { + color: "#9ca3af", + fontSize: 12, + fontWeight: "500", + }, + mealDeleteBtn: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, + emptyMealState: { + paddingVertical: 20, + paddingHorizontal: 16, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255,255,255,0.03)", + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.05)", + borderStyle: "dashed", + width: "100%", + }, + emptyMealText: { + fontSize: 14, + color: "#6b7280", + fontWeight: "500", + fontStyle: "italic", + }, + actionButtons: { + marginTop: 24, + gap: 12, + }, + saveButton: { + borderRadius: 16, + overflow: "hidden", + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + saveButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 18, + }, + saveButtonText: { + fontSize: 17, + fontWeight: "700", + color: "#111827", + letterSpacing: 0.5, + }, + refreshButton: { + borderRadius: 16, + overflow: "hidden", + }, + refreshButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.2)", + borderRadius: 16, + }, + refreshButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + }, + navigation: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginTop: 20, + marginBottom: 40, + marginHorizontal: 20, + }, + navBtn: { + width: 50, + height: 50, + borderRadius: 25, + overflow: "hidden", + }, + navBtnGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 25, + }, + dayIndicator: { + paddingHorizontal: 20, + paddingVertical: 8, + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + dayIndicatorText: { + fontSize: 14, + color: "#9ca3af", + fontWeight: "600", + letterSpacing: 0.5, + }, +}); + +export default MealRecommendScreen; diff --git a/src/screens/diet/TempMealRecommendScreen.tsx b/src/screens/diet/TempMealRecommendScreen.tsx index 7b454a2..219f1fc 100644 --- a/src/screens/diet/TempMealRecommendScreen.tsx +++ b/src/screens/diet/TempMealRecommendScreen.tsx @@ -1,1984 +1,1735 @@ -// src/screens/diet/TempMealRecommendScreen.tsx - -import React, { useState, useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - TextInput, - Alert, - ActivityIndicator, - Dimensions, - Modal, - Animated, - Easing, -} from "react-native"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import { useNavigation } from "@react-navigation/native"; -import { NativeStackNavigationProp } from "@react-navigation/native-stack"; -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"; -// 🔹 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"); - -const LOADING_MESSAGES = [ - "입력하신 정보를 수집하는 중...", - "회원님께 최적화된 식단을 준비하는 중...", - "영양소 균형을 계산하는 중...", - "맛있는 조합을 찾는 중...", - "거의 다 됐어요! 조금만 기다려주세요...", -]; - -// ... (LoadingOverlay, MealsSelectionModal, mealsModalStyles, Interface 등은 기존과 동일하므로 생략) ... -// (위쪽 코드는 변경사항이 없으므로 그대로 두시면 됩니다. TempMealRecommendScreen 컴포넌트 내부만 수정합니다.) - -const LoadingOverlay = ({ - visible, - messages = LOADING_MESSAGES, - onCancel, -}: { - visible: boolean; - 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; - const scaleAnim = useRef(new Animated.Value(1)).current; - - useEffect(() => { - if (!visible) { - setCurrentMessageIndex(0); - fadeAnim.setValue(1); - return; - } - - Animated.loop( - Animated.timing(spinAnim, { - toValue: 1, - duration: 2000, - useNativeDriver: true, - easing: Easing.linear, - }) - ).start(); - - Animated.loop( - Animated.sequence([ - Animated.timing(scaleAnim, { - toValue: 1.1, - duration: 1000, - useNativeDriver: true, - easing: Easing.inOut(Easing.ease), - }), - Animated.timing(scaleAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - easing: Easing.inOut(Easing.ease), - }), - ]) - ).start(); - - fadeAnim.setValue(1); - - const interval = setInterval(() => { - Animated.timing(fadeAnim, { - toValue: 0, - duration: 500, - useNativeDriver: true, - easing: Easing.out(Easing.ease), - }).start(() => { - setCurrentMessageIndex((prev) => (prev + 1) % messages.length); - Animated.timing(fadeAnim, { - toValue: 1, - duration: 800, - useNativeDriver: true, - easing: Easing.in(Easing.ease), - }).start(); - }); - }, 3500); - - return () => clearInterval(interval); - }, [visible, messages.length]); - - const spin = spinAnim.interpolate({ - inputRange: [0, 1], - outputRange: ["0deg", "360deg"], - }); - - return ( - - - - - - - - - - - - - - {messages && messages.length > 0 - ? messages[currentMessageIndex] - : "로딩 중..."} - - - - {onCancel && ( - - - 요청 취소하기 - - - )} - - - - ); -}; - -const MealsSelectionModal = ({ - visible, - currentMeals, - onSelect, - onClose, -}: { - visible: boolean; - currentMeals: number; - onSelect: (meals: number) => void; - onClose: () => void; -}) => { - return ( - - - - - - {/* 헤더 */} - - - 끼니 수 선택 - - - - 하루에 몇 끼를 드시나요? - - - {/* 끼니 선택 버튼들 */} - - {[1, 2, 3].map((num) => ( - { - onSelect(num); - onClose(); - }} - activeOpacity={0.8} - > - - - - {currentMeals === num ? ( - - ) : ( - - )} - - - - {num}끼 - - - {num === 1 - ? "하루 1끼" - : num === 2 - ? "아침 + 점심 또는 점심 + 저녁" - : "아침 + 점심 + 저녁"} - - - - - - ))} - - - {/* 닫기 버튼 */} - - 취소 - - - - - - - ); -}; - -const mealsModalStyles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.8)", - justifyContent: "center", - alignItems: "center", - }, - container: { - width: "85%", - maxWidth: 400, - }, - content: { - borderRadius: 24, - padding: 28, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - }, - header: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 12, - marginBottom: 12, - }, - title: { - fontSize: 24, - fontWeight: "800", - color: "#ffffff", - letterSpacing: 0.5, - }, - subtitle: { - fontSize: 15, - color: "#9ca3af", - textAlign: "center", - marginBottom: 28, - letterSpacing: 0.3, - }, - optionsContainer: { - gap: 12, - marginBottom: 24, - }, - optionButton: { - borderRadius: 16, - overflow: "hidden", - }, - optionGradient: { - borderRadius: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - optionContent: { - flexDirection: "row", - alignItems: "center", - padding: 18, - gap: 16, - }, - optionIconContainer: { - width: 48, - height: 48, - alignItems: "center", - justifyContent: "center", - }, - optionTextContainer: { - flex: 1, - }, - optionNumber: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - marginBottom: 4, - letterSpacing: 0.3, - }, - optionNumberActive: { - color: "#111827", - }, - optionDesc: { - fontSize: 13, - color: "#9ca3af", - letterSpacing: 0.2, - }, - optionDescActive: { - color: "rgba(17,24,39,0.7)", - }, - closeButton: { - paddingVertical: 14, - alignItems: "center", - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 12, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - closeButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#9ca3af", - letterSpacing: 0.3, - }, -}); - -// UI에 표시할 데이터 구조 -interface MealItem { - mealType: string; - mealTypeName: string; - foods: FoodItem[]; - totalCalories: number; -} - -interface FoodItem { - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; -} - -const TempMealRecommendScreen: React.FC = () => { - const navigation = useNavigation(); - - const [userId, setUserId] = useState(""); - - const [screen, setScreen] = useState< - "input" | "excludedIngredients" | "result" - >("input"); - const [currentDayTab, setCurrentDayTab] = useState(0); - - const [excludedFoods, setExcludedFoods] = useState([]); - - const [newIngredient, setNewIngredient] = useState(""); - const [selectedPeriod, setSelectedPeriod] = useState<"daily" | "weekly">( - "daily" - ); - const [recommendedMeals, setRecommendedMeals] = useState([]); - const [loading, setLoading] = useState(false); - - const [mealsPerDay, setMealsPerDay] = useState(3); - const [showMealsModal, setShowMealsModal] = useState(false); - - const [isTokenDepleted, setIsTokenDepleted] = useState(false); - - const [dailyNutrition, setDailyNutrition] = useState({ - calories: 0, - carbs: 0, - protein: 0, - fat: 0, - }); - - const fadeAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }).start(); - }, [screen]); - - // 🔹 [변경] 데이터 로드 로직 수정: 프로필 조회 후 -> 식단 조회 - useEffect(() => { - const init = async () => { - await loadUserData(); - }; - init(); - }, []); - - const loadUserData = async () => { - try { - // 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) { - setExcludedFoods(JSON.parse(stored)); - } - } catch (e) { - console.error("로컬 스토리지 읽기 실패:", e); - } - } - }; - - const handleCancelLoading = () => { - Alert.alert("요청 취소", "식단 추천 요청을 취소하시겠습니까?", [ - { text: "계속 기다리기", style: "cancel" }, - { - text: "취소", - style: "destructive", - onPress: () => { - setLoading(false); - }, - }, - ]); - }; - - const handleGenerate = async () => { - if (isTokenDepleted) { - Alert.alert("알림", "이미 이번 주 추천 횟수를 모두 사용했습니다."); - return; - } - - try { - setLoading(true); - - console.log("🍽️ 임시 식단 생성 시작"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - - const weeklyData: TempDayMeal[] = - await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); - - if (!weeklyData || weeklyData.length === 0) { - throw new Error("추천된 식단 데이터가 없습니다."); - } - - const todayPlan = weeklyData[0]; - const mealTypeMap: { [key: string]: string } = { - BREAKFAST: "아침", - LUNCH: "점심", - DINNER: "저녁", - }; - - let totalCal = 0, - totalCarb = 0, - totalProt = 0, - totalFat = 0; - - const parsedMeals: MealItem[] = - todayPlan.meals?.map((meal) => { - totalCal += meal.totalCalories; - meal.foods.forEach((f) => { - totalCarb += f.carbs || 0; - totalProt += f.protein || 0; - totalFat += f.fat || 0; - }); - - const foods: FoodItem[] = - meal.foods?.map((food) => ({ - foodName: food.foodName || "알 수 없음", - servingSize: Math.round(food.servingSize || 100), - calories: Math.round(food.calories || 0), - carbs: Math.round(food.carbs || 0), - protein: Math.round(food.protein || 0), - fat: Math.round(food.fat || 0), - })) || []; - - return { - mealType: meal.mealType, - mealTypeName: mealTypeMap[meal.mealType] || meal.mealType, - foods, - totalCalories: Math.round(meal.totalCalories), - }; - }) || []; - - const order = { BREAKFAST: 1, LUNCH: 2, DINNER: 3 }; - parsedMeals.sort((a, b) => { - const orderA = order[a.mealType as keyof typeof order] || 4; - const orderB = order[b.mealType as keyof typeof order] || 4; - return orderA - orderB; - }); - - setRecommendedMeals(parsedMeals); - setDailyNutrition({ - calories: Math.round(totalCal), - carbs: Math.round(totalCarb), - protein: Math.round(totalProt), - fat: Math.round(totalFat), - }); - - setScreen("result"); - setCurrentDayTab(0); - - Alert.alert("성공", "오늘의 식단이 추천되었습니다!"); - } catch (error: any) { - console.error("식단 생성 오류:", error); - - const errorMessage = error.message || ""; - if ( - errorMessage.includes("토큰이 부족") || - errorMessage.includes("무료 식단 추천") || - error.status === 403 - ) { - setIsTokenDepleted(true); - } else { - let finalErrorMessage = errorMessage || "식단 생성에 실패했습니다."; - if (error.status === 500) { - finalErrorMessage = - "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; - } - Alert.alert("오류", finalErrorMessage); - } - } finally { - setLoading(false); - } - }; - - const handleSave = async () => { - try { - setLoading(true); - console.log("💾 식단 저장 요청 (Server Commit)"); - await recommendedMealAPI.saveTempMealPlan(); - - Alert.alert( - "저장 완료! 🎉", - "AI 추천 식단이 기록되었습니다.\n기록하기 화면에서 확인하세요.", - [ - { - text: "확인", - onPress: () => navigation.goBack(), - }, - ] - ); - } catch (error: any) { - console.error("❌ 저장 오류:", error); - Alert.alert("오류", error.message || "저장에 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleTabPress = (index: number) => { - if (index > 0) { - Alert.alert( - "🔒 프리미엄 기능", - "무료 회원은 1일차 식단만 확인할 수 있습니다.\n7일 전체 식단을 보려면 프리미엄으로 업그레이드하세요.", - [ - { text: "닫기", style: "cancel" }, - { - text: "업그레이드", - onPress: () => - navigation.navigate("Main", { - screen: "MyPage", - params: { openPremiumModal: true }, - } as any), - }, - ] - ); - } else { - setCurrentDayTab(index); - } - }; - - // ✅ 금지 식재료 추가 - const handleAddExcludedIngredient = async () => { - const trimmed = newIngredient.trim(); - - if (!trimmed) return; - if (!userId) { - Alert.alert( - "오류", - "유저 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요." - ); - return; - } - - const isDuplicate = excludedFoods.some( - (item) => item.food_name === trimmed - ); - - if (isDuplicate) { - Alert.alert("알림", "이미 추가된 식재료입니다."); - return; - } - - try { - setLoading(true); - - // 🔹 [변경] state로 저장된 userId 사용 - const newItem = await userPreferencesAPI.addExclusions(userId, [trimmed]); - - 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 || "식재료 추가에 실패했습니다."); - } finally { - setLoading(false); - } - }; - - // ✅ 금지 식재료 삭제 - const handleRemoveExcludedIngredient = async (id: number, name: string) => { - Alert.alert("삭제", `"${name}"를 삭제하시겠습니까?`, [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - await userPreferencesAPI.deleteExclusion(id); - - const updatedList = excludedFoods.filter((item) => item.id !== id); - setExcludedFoods(updatedList); - - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(updatedList) - ); - - console.log("✅ 비선호 음식 삭제 완료 (ID):", id); - } catch (error: any) { - console.error("비선호 음식 삭제 실패:", error); - Alert.alert("오류", error.message || "식재료 삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ]); - }; - - // ... (나머지 render 코드는 UI만 그리므로 수정사항 없이 위에서 제공한 코드와 동일합니다. - // styles나 loadingStyles도 동일합니다.) - - // ✅ input 화면 (웰컴 화면) - if (screen === "input") { - return ( - - - - - - - { - setMealsPerDay(num); - console.log(`✅ 끼니 수 변경: ${num}끼`); - }} - onClose={() => setShowMealsModal(false)} - /> - - - navigation.goBack()} - style={styles.backButton} - > - - - - - 식단 추천 - - - - - - - - - - 무료 회원 - - - - - 일주일에 1회 무료 추천! - - - 더 많은 식단 추천을 받으려면 프리미엄으로 업그레이드하세요 - - - navigation.navigate("Main", { - screen: "MyPage", - params: { openPremiumModal: true }, - } as any) - } - activeOpacity={0.9} - > - - - - 프리미엄으로 무제한 추천받기 - - - - - - - - {/* ✅ 금지 식재료 관리 버튼 */} - setScreen("excludedIngredients")} - activeOpacity={0.8} - > - - - - 금지 식재료 관리 - {excludedFoods.length > 0 && ` (${excludedFoods.length})`} - - - - - {/* ✅ 끼니 수정하기 버튼 */} - setShowMealsModal(true)} - activeOpacity={0.8} - > - - - - 끼니 수정하기 ({mealsPerDay}끼) - - - - - {/* 금지 식재료 미리보기 */} - {excludedFoods.length > 0 && ( - - - 제외된 식재료 - - {excludedFoods.map((item) => ( - - - {item.food_name} - - - ))} - - - - )} - - - - - 식단 기간 - - - - setSelectedPeriod("daily")} - activeOpacity={0.8} - > - {selectedPeriod === "daily" ? ( - - - - 1일 식단 - - - 오늘 하루 - - - ) : ( - - - 1일 식단 - 오늘 하루 - - )} - - - - Alert.alert( - "프리미엄 기능", - "7일 식단 추천은 프리미엄 전용입니다.\n업그레이드 하시겠습니까?", - [{ text: "취소", style: "cancel" }] - ) - } - activeOpacity={0.8} - > - - - - - - - 7일 식단 - - - - 프리미엄 - - - - - - - - - - - - 💡 AI가 사용자의 취향과 영양 균형을 분석하여 맞춤형 식단을 - 제안합니다. - - - - - - - - {loading ? ( - <> - - 생성 중... - - ) : ( - <> - - - {isTokenDepleted - ? "이번주 추천 완료" - : "오늘의 식단 추천받기"} - - - )} - - - - {isTokenDepleted && ( - - - - 이번 주 무료 추천을 모두 사용하였습니다.{"\n"} - 다음 주 월요일에 다시 추천 받을 수 있습니다. - - - )} - - - - ); - } - - // ✅ 금지 식재료 관리 화면 - if (screen === "excludedIngredients") { - return ( - - - - - - setScreen("input")} - style={styles.backButton} - > - - - - - 금지 식재료 - - - - - - - - - - - - - - - - - - - - {excludedFoods.map((item, index) => ( - - - - - - - - {item.food_name} - - - - handleRemoveExcludedIngredient(item.id, item.food_name) - } - activeOpacity={0.7} - > - - - - - ))} - - {excludedFoods.length === 0 && ( - - - - 등록된 금지 식재료가 없습니다 - - - 알러지나 선호하지 않는 식재료를 추가하세요 - - - )} - - - setScreen("input")} - activeOpacity={0.9} - > - - - 완료 - - - - - - ); - } - - // ✅ 결과 화면 - return ( - - - - - - - - setScreen("input")} - style={styles.backButton} - > - - - - - 추천 결과 - - - - - - - {Array.from({ length: 7 }).map((_, index) => { - const isLocked = index > 0; - const isActive = currentDayTab === index; - - return ( - handleTabPress(index)} - activeOpacity={0.8} - style={styles.dayTabTouch} - > - {isActive ? ( - - - {index + 1}일차 - - - ) : ( - - {isLocked && ( - - )} - {index + 1}일차 - - )} - - ); - })} - - - - - - - - 오늘의 영양 섭취 - - - - {dailyNutrition.calories} kcal - - - - - - - 탄수화물 - - {dailyNutrition.carbs}g - - - - - 단백질 - - {dailyNutrition.protein}g - - - - - 지방 - - {dailyNutrition.fat}g - - - - - - - {recommendedMeals.map((meal, index) => ( - - - - - - - - {meal.mealTypeName === "아침" - ? "🌅" - : meal.mealTypeName === "점심" - ? "☀️" - : "🌙"} - - - - - {meal.mealTypeName} - - {meal.mealTypeName === "아침" - ? "07:00 - 09:00" - : meal.mealTypeName === "점심" - ? "12:00 - 14:00" - : "18:00 - 20:00"} - - - - - - {meal.totalCalories} - - kcal - - - - - {meal.foods.map((food, fIdx) => ( - - - - {food.foodName} {food.servingSize}g - - - ({food.calories}kcal) - - - - ))} - - - - ))} - - - - - - 이 식단 저장하기 - - - - setScreen("input")} - > - 다시 설정하기 - - - - - - - ); -}; - -const loadingStyles = StyleSheet.create({ - overlay: { - flex: 1, - justifyContent: "center", - alignItems: "center", - }, - container: { - alignItems: "center", - justifyContent: "center", - width: "100%", - paddingHorizontal: 20, - }, - spinnerContainer: { - marginBottom: 40, - }, - spinnerOuter: { - width: 100, - height: 100, - borderRadius: 50, - borderWidth: 4, - borderColor: "rgba(227, 255, 124, 0.2)", - alignItems: "center", - justifyContent: "center", - }, - spinnerInner: { - width: 80, - height: 80, - borderRadius: 40, - borderWidth: 4, - borderColor: "#e3ff7c", - borderTopColor: "transparent", - borderRightColor: "transparent", - }, - textContainer: { - width: "100%", - alignItems: "center", - justifyContent: "center", - minHeight: 70, - }, - message: { - fontSize: 18, - fontWeight: "600", - color: "#ffffff", - textAlign: "center", - lineHeight: 28, - letterSpacing: 0.5, - }, - cancelButton: { - marginTop: 50, - borderRadius: 30, - overflow: "hidden", - }, - cancelButtonGradient: { - paddingVertical: 14, - paddingHorizontal: 28, - borderWidth: 1, - borderColor: "rgba(255, 255, 255, 0.2)", - borderRadius: 30, - }, - cancelText: { - color: "#ffffff", - fontSize: 15, - fontWeight: "600", - letterSpacing: 0.5, - }, -}); - -const styles = StyleSheet.create({ - container: { flex: 1 }, - safeArea: { flex: 1 }, - contentWrapper: { flex: 1 }, - contentContainer: { paddingHorizontal: 20, paddingBottom: 40 }, - resultContainer: { paddingHorizontal: 20, paddingBottom: 40, paddingTop: 10 }, - - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 20, - }, - backButton: { width: 40, height: 40 }, - iconButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: "rgba(255,255,255,0.1)", - alignItems: "center", - justifyContent: "center", - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - letterSpacing: 0.5, - }, - - freeUserBannerContainer: { - marginTop: 20, - marginBottom: 24, - borderRadius: 20, - overflow: "hidden", - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 12, - elevation: 6, - }, - freeUserBanner: { padding: 20, gap: 12, borderRadius: 20 }, - freeUserBadge: { - alignSelf: "flex-start", - borderRadius: 12, - overflow: "hidden", - }, - freeUserBadgeGradient: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingHorizontal: 12, - paddingVertical: 6, - }, - freeUserBadgeText: { fontSize: 12, fontWeight: "700", color: "#111827" }, - freeUserTitle: { fontSize: 20, fontWeight: "700", color: "#e3ff7c" }, - freeUserSubtitle: { fontSize: 14, color: "#9ca3af", lineHeight: 20 }, - premiumButton: { borderRadius: 12, overflow: "hidden", marginTop: 4 }, - premiumButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, - paddingVertical: 14, - }, - premiumButtonText: { fontSize: 15, fontWeight: "700", color: "#111827" }, - - // ✅ 설정 버튼 스타일 (금지 식재료, 끼니 수정) - settingButton: { - borderRadius: 16, - overflow: "hidden", - marginBottom: 12, - }, - settingButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 16, - paddingHorizontal: 24, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.2)", - borderRadius: 16, - }, - settingButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - letterSpacing: 0.3, - }, - - // 금지 식재료 미리보기 - excludedPreview: { - marginTop: 8, - marginBottom: 20, - }, - glassCard: { - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 16, - padding: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - excludedPreviewLabel: { - fontSize: 15, - color: "#9ca3af", - marginBottom: 16, - fontWeight: "600", - letterSpacing: 0.3, - }, - tagList: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - tag: { - borderRadius: 12, - overflow: "hidden", - }, - tagGradient: { - paddingVertical: 8, - paddingHorizontal: 14, - borderRadius: 12, - }, - tagText: { - fontSize: 14, - color: "#ffffff", - fontWeight: "500", - }, - - section: { marginBottom: 24 }, - sectionHeader: { - flexDirection: "row", - alignItems: "center", - gap: 8, - marginBottom: 12, - }, - sectionTitle: { fontSize: 18, fontWeight: "700", color: "#ffffff" }, - periodButtons: { flexDirection: "row", gap: 12 }, - periodButtonContainer: { flex: 1, borderRadius: 16, overflow: "hidden" }, - periodButton: { - backgroundColor: "rgba(255,255,255,0.05)", - padding: 20, - borderRadius: 16, - alignItems: "center", - gap: 8, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - periodButtonLocked: { opacity: 0.7 }, - periodButtonText: { fontSize: 16, fontWeight: "700", color: "#e3ff7c" }, - periodButtonTextActive: { fontSize: 16, fontWeight: "700", color: "#111827" }, - periodButtonTextLocked: { color: "#6b7280" }, - periodButtonSubtext: { fontSize: 12, color: "#6b7280", fontWeight: "500" }, - periodButtonSubtextActive: { - fontSize: 12, - color: "#374151", - fontWeight: "600", - }, - lockOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0,0,0,0.5)", - justifyContent: "center", - alignItems: "center", - zIndex: 1, - }, - premiumTag: { - flexDirection: "row", - alignItems: "center", - gap: 4, - backgroundColor: "rgba(227,255,124,0.1)", - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - premiumTagText: { fontSize: 10, fontWeight: "700", color: "#e3ff7c" }, - - infoBox: { marginBottom: 20, borderRadius: 14, overflow: "hidden" }, - infoBoxGradient: { - flexDirection: "row", - alignItems: "flex-start", - gap: 12, - padding: 16, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - borderRadius: 14, - }, - infoTextContainer: { flex: 1 }, - infoText: { - fontSize: 13, - color: "#e3ff7c", - lineHeight: 18, - fontWeight: "500", - }, - - generateButtonContainer: { - borderRadius: 16, - overflow: "hidden", - marginBottom: 12, - }, - generateButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 10, - paddingVertical: 18, - }, - generateButtonText: { fontSize: 17, fontWeight: "700", color: "#111827" }, - - limitInfoContainer: { - marginTop: 8, - alignItems: "center", - paddingVertical: 10, - }, - limitInfoText: { - fontSize: 13, - color: "#9ca3af", - textAlign: "center", - lineHeight: 20, - }, - - // ✅ 금지 식재료 관리 화면 스타일 - 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, - }, - - // 결과 화면 스타일 - dayTabsWrapper: { marginBottom: 16 }, - dayTabsContent: { paddingHorizontal: 20, paddingBottom: 10, gap: 10 }, - dayTabTouch: { borderRadius: 20, overflow: "hidden" }, - dayTab: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 20, - backgroundColor: "rgba(255,255,255,0.05)", - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - }, - dayTabLocked: { - backgroundColor: "rgba(0,0,0,0.3)", - borderColor: "rgba(255,255,255,0.05)", - }, - dayTabActive: { - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 20, - }, - dayTabText: { fontSize: 14, color: "#9ca3af", fontWeight: "600" }, - dayTabTextActive: { fontSize: 14, color: "#111827", fontWeight: "700" }, - - nutritionCard: { borderRadius: 20, overflow: "hidden", marginBottom: 20 }, - nutritionCardGradient: { - padding: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 20, - }, - nutritionHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 16, - }, - nutritionTitle: { fontSize: 16, fontWeight: "700", color: "#fff" }, - totalCalBadge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "#e3ff7c", - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 12, - gap: 4, - }, - totalCalText: { fontSize: 14, fontWeight: "700", color: "#111827" }, - nutritionRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - nutrientItem: { alignItems: "center", flex: 1 }, - nutrientLabel: { fontSize: 12, color: "#9ca3af", marginBottom: 4 }, - nutrientValue: { fontSize: 16, fontWeight: "700", color: "#fff" }, - nutrientDivider: { - width: 1, - height: 30, - backgroundColor: "rgba(255,255,255,0.1)", - }, - - mealCardContainer: { marginBottom: 16, borderRadius: 18, overflow: "hidden" }, - mealCard: { - padding: 20, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 18, - }, - mealCardHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 16, - }, - mealTitleRow: { flexDirection: "row", alignItems: "center", gap: 12 }, - mealIconContainer: { - width: 48, - height: 48, - borderRadius: 24, - overflow: "hidden", - }, - mealIcon: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - mealEmoji: { fontSize: 24 }, - mealName: { fontSize: 18, fontWeight: "700", color: "#ffffff" }, - mealTime: { fontSize: 12, color: "#6b7280", marginTop: 2, fontWeight: "500" }, - mealCaloriesContainer: { flexDirection: "row", alignItems: "baseline" }, - mealCalories: { fontSize: 20, fontWeight: "700", color: "#ffffff" }, - mealCaloriesUnit: { - fontSize: 14, - color: "#6b7280", - marginLeft: 2, - fontWeight: "600", - }, - foodsList: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - foodTag: { borderRadius: 14, overflow: "hidden" }, - foodTagItemGradient: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingVertical: 10, - paddingHorizontal: 14, - borderWidth: 1, - borderColor: "rgba(227,255,124,0.2)", - borderRadius: 14, - }, - foodName: { fontSize: 14, color: "#ffffff", fontWeight: "600" }, - foodCalories: { fontSize: 12, color: "#9ca3af", fontWeight: "500" }, - - resultActions: { gap: 12, marginTop: 10 }, - saveButtonContainer: { borderRadius: 16, overflow: "hidden" }, - saveButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, - paddingVertical: 18, - }, - saveButtonText: { fontSize: 17, fontWeight: "700", color: "#111827" }, - retryButton: { alignItems: "center", padding: 16 }, - retryButtonText: { - color: "#9ca3af", - fontSize: 14, - textDecorationLine: "underline", - }, -}); - -export default TempMealRecommendScreen; +// src/screens/diet/TempMealRecommendScreen.tsx + +import React, { useState, useEffect, useRef } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, + Modal, + Animated, + Easing, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; +import { NativeStackNavigationProp } from "@react-navigation/native-stack"; +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 { recommendedMealAPI, authAPI } from "../../services"; +import { TempDayMeal } from "../../services/recommendedMealAPI"; +import type { ExclusionResponse, PreferenceResponse } from "../../types"; +import { userPreferencesAPI } from "../../services/userPreferencesAPI"; +import DislikedFoodsModal from "../../components/modals/DislikedFoodsModal"; +import PreferredFoodsModal from "../../components/modals/PreferredFoodsModal"; + +type NavigationProp = NativeStackNavigationProp; + +const LOADING_MESSAGES = [ + "입력하신 정보를 수집하는 중...", + "회원님께 최적화된 식단을 준비하는 중...", + "영양소 균형을 계산하는 중...", + "맛있는 조합을 찾는 중...", + "거의 다 됐어요! 조금만 기다려주세요...", +]; + +const LoadingOverlay = ({ + visible, + messages = LOADING_MESSAGES, + onCancel, +}: { + visible: boolean; + 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; + const scaleAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + if (!visible) { + setCurrentMessageIndex(0); + fadeAnim.setValue(1); + return; + } + + Animated.loop( + Animated.timing(spinAnim, { + toValue: 1, + duration: 2000, + useNativeDriver: true, + easing: Easing.linear, + }) + ).start(); + + Animated.loop( + Animated.sequence([ + Animated.timing(scaleAnim, { + toValue: 1.1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + Animated.timing(scaleAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + easing: Easing.inOut(Easing.ease), + }), + ]) + ).start(); + + fadeAnim.setValue(1); + + const interval = setInterval(() => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + easing: Easing.out(Easing.ease), + }).start(() => { + setCurrentMessageIndex((prev) => (prev + 1) % messages.length); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + easing: Easing.in(Easing.ease), + }).start(); + }); + }, 3500); + + return () => clearInterval(interval); + }, [visible, messages.length]); + + const spin = spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + + return ( + + + + + + + + + + + + + + {messages && messages.length > 0 + ? messages[currentMessageIndex] + : "로딩 중..."} + + + + {onCancel && ( + + + 요청 취소하기 + + + )} + + + + ); +}; + +const MealsSelectionModal = ({ + visible, + currentMeals, + onSelect, + onClose, +}: { + visible: boolean; + currentMeals: number; + onSelect: (meals: number) => void; + onClose: () => void; +}) => { + return ( + + + + + + + + 끼니 수 선택 + + + + 하루에 몇 끼를 드시나요? + + + + {[1, 2, 3].map((num) => ( + { + onSelect(num); + onClose(); + }} + activeOpacity={0.8} + > + + + + {currentMeals === num ? ( + + ) : ( + + )} + + + + {num}끼 + + + {num === 1 + ? "하루 1끼" + : num === 2 + ? "아침 + 점심 또는 점심 + 저녁" + : "아침 + 점심 + 저녁"} + + + + + + ))} + + + + 취소 + + + + + + + ); +}; + +interface MealItem { + mealType: string; + mealTypeName: string; + foods: FoodItem[]; + totalCalories: number; +} + +interface FoodItem { + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; +} + +const TempMealRecommendScreen: React.FC = () => { + const navigation = useNavigation(); + + const [userId, setUserId] = useState(""); + + const [screen, setScreen] = useState<"input" | "result">("input"); + const [currentDayTab, setCurrentDayTab] = useState(0); + + const [excludedFoods, setExcludedFoods] = useState([]); + const [preferredIngredients, setPreferredIngredients] = useState< + PreferenceResponse[] + >([]); + + const [showDislikedModal, setShowDislikedModal] = useState(false); + const [showPreferredModal, setShowPreferredModal] = useState(false); + + const [selectedPeriod, setSelectedPeriod] = useState<"daily" | "weekly">( + "daily" + ); + const [recommendedMeals, setRecommendedMeals] = useState([]); + const [loading, setLoading] = useState(false); + + const [mealsPerDay, setMealsPerDay] = useState(3); + const [showMealsModal, setShowMealsModal] = useState(false); + + const [isTokenDepleted, setIsTokenDepleted] = useState(false); + + const [dailyNutrition, setDailyNutrition] = useState({ + calories: 0, + carbs: 0, + protein: 0, + fat: 0, + }); + + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }).start(); + }, [screen]); + + useEffect(() => { + const init = async () => { + await loadUserData(); + }; + init(); + }, []); + + const loadUserData = async () => { + try { + const profile = await authAPI.getProfile(); + const currentUserId = profile.userId; + setUserId(currentUserId); + console.log("✅ 유저 ID 로드 완료:", currentUserId); + + const exclusions = await userPreferencesAPI.getExclusions(currentUserId); + setExcludedFoods(exclusions); + console.log("✅ 비선호 음식 로드 완료:", exclusions.length, "개"); + + const preferences = await userPreferencesAPI.getPreferences( + currentUserId + ); + setPreferredIngredients(preferences); + console.log("✅ 선호 음식 로드 완료:", preferences.length, "개"); + } catch (error) { + console.error("사용자 데이터 로드 실패:", error); + try { + const stored = await AsyncStorage.getItem("excludedIngredients"); + if (stored) { + setExcludedFoods(JSON.parse(stored)); + } + } catch (e) { + console.error("로컬 스토리지 읽기 실패:", e); + } + } + }; + + const loadPreferences = async (currentUserId: string) => { + try { + console.log("💚 선호 음식 목록 조회:", currentUserId); + const preferences = await userPreferencesAPI.getPreferences( + currentUserId + ); + setPreferredIngredients(preferences); + console.log("✅ 선호 음식 로드 완료:", preferences.length, "개"); + } catch (error: any) { + console.error("선호 음식 로드 실패:", error); + } + }; + + const loadExclusions = async (currentUserId: string) => { + try { + console.log("🚫 비선호 음식 목록 조회:", currentUserId); + const exclusions = await userPreferencesAPI.getExclusions(currentUserId); + setExcludedFoods(exclusions); + console.log("✅ 비선호 음식 로드 완료:", exclusions.length, "개"); + } catch (error: any) { + console.error("비선호 음식 로드 실패:", error); + } + }; + + const handleCancelLoading = () => { + Alert.alert("요청 취소", "식단 추천 요청을 취소하시겠습니까?", [ + { text: "계속 기다리기", style: "cancel" }, + { + text: "취소", + style: "destructive", + onPress: () => { + setLoading(false); + }, + }, + ]); + }; + + const handleGenerate = async () => { + if (isTokenDepleted) { + Alert.alert("알림", "이미 이번 주 추천 횟수를 모두 사용했습니다."); + return; + } + + try { + setLoading(true); + + console.log("🍽️ 임시 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + + const weeklyData: TempDayMeal[] = + await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); + + if (!weeklyData || weeklyData.length === 0) { + throw new Error("추천된 식단 데이터가 없습니다."); + } + + const todayPlan = weeklyData[0]; + const mealTypeMap: { [key: string]: string } = { + BREAKFAST: "아침", + LUNCH: "점심", + DINNER: "저녁", + }; + + let totalCal = 0, + totalCarb = 0, + totalProt = 0, + totalFat = 0; + + const parsedMeals: MealItem[] = + todayPlan.meals?.map((meal) => { + totalCal += meal.totalCalories; + meal.foods.forEach((f) => { + totalCarb += f.carbs || 0; + totalProt += f.protein || 0; + totalFat += f.fat || 0; + }); + + const foods: FoodItem[] = + meal.foods?.map((food) => ({ + foodName: food.foodName || "알 수 없음", + servingSize: Math.round(food.servingSize || 100), + calories: Math.round(food.calories || 0), + carbs: Math.round(food.carbs || 0), + protein: Math.round(food.protein || 0), + fat: Math.round(food.fat || 0), + })) || []; + + return { + mealType: meal.mealType, + mealTypeName: mealTypeMap[meal.mealType] || meal.mealType, + foods, + totalCalories: Math.round(meal.totalCalories), + }; + }) || []; + + const order = { BREAKFAST: 1, LUNCH: 2, DINNER: 3 }; + parsedMeals.sort((a, b) => { + const orderA = order[a.mealType as keyof typeof order] || 4; + const orderB = order[b.mealType as keyof typeof order] || 4; + return orderA - orderB; + }); + + setRecommendedMeals(parsedMeals); + setDailyNutrition({ + calories: Math.round(totalCal), + carbs: Math.round(totalCarb), + protein: Math.round(totalProt), + fat: Math.round(totalFat), + }); + + setScreen("result"); + setCurrentDayTab(0); + + Alert.alert("성공", "오늘의 식단이 추천되었습니다!"); + } catch (error: any) { + console.error("식단 생성 오류:", error); + + const errorMessage = error.message || ""; + if ( + errorMessage.includes("토큰이 부족") || + errorMessage.includes("무료 식단 추천") || + error.status === 403 + ) { + setIsTokenDepleted(true); + } else { + let finalErrorMessage = errorMessage || "식단 생성에 실패했습니다."; + if (error.status === 500) { + finalErrorMessage = + "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; + } + Alert.alert("오류", finalErrorMessage); + } + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + try { + setLoading(true); + console.log("💾 식단 저장 요청 (Server Commit)"); + await recommendedMealAPI.saveTempMealPlan(); + + Alert.alert( + "저장 완료! 🎉", + "AI 추천 식단이 기록되었습니다.\n기록하기 화면에서 확인하세요.", + [ + { + text: "확인", + onPress: () => navigation.goBack(), + }, + ] + ); + } catch (error: any) { + console.error("❌ 저장 오류:", error); + Alert.alert("오류", error.message || "저장에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleTabPress = (index: number) => { + if (index > 0) { + Alert.alert( + "🔒 프리미엄 기능", + "무료 회원은 1일차 식단만 확인할 수 있습니다.\n7일 전체 식단을 보려면 프리미엄으로 업그레이드하세요.", + [ + { text: "닫기", style: "cancel" }, + { + text: "업그레이드", + onPress: () => + navigation.navigate("Main", { + screen: "MyPage", + params: { openPremiumModal: true }, + } as any), + }, + ] + ); + } else { + setCurrentDayTab(index); + } + }; + + if (screen === "input") { + return ( + + + + + + + { + setMealsPerDay(num); + console.log(`✅ 끼니 수 변경: ${num}끼`); + }} + onClose={() => setShowMealsModal(false)} + /> + + setShowDislikedModal(false)} + onUpdate={() => loadExclusions(userId)} + /> + + setShowPreferredModal(false)} + onUpdate={() => loadPreferences(userId)} + /> + + + navigation.goBack()} + style={styles.backButton} + > + + + + + 식단 추천 + + + + + + + + + + 무료 회원 + + + + + 일주일에 1회 무료 추천! + + + 더 많은 식단 추천을 받으려면 프리미엄으로 업그레이드하세요 + + + navigation.navigate("Main", { + screen: "MyPage", + params: { openPremiumModal: true }, + } as any) + } + activeOpacity={0.9} + > + + + + 프리미엄으로 무제한 추천받기 + + + + + + + + + setShowDislikedModal(true)} + activeOpacity={0.8} + > + + + 금지 식재료 + + + + setShowPreferredModal(true)} + activeOpacity={0.8} + > + + + 선호 식재료 + + + + + setShowMealsModal(true)} + activeOpacity={0.8} + > + + + + 끼니 수정하기 ({mealsPerDay}끼) + + + + + {excludedFoods.length > 0 && ( + + + + + + 제외된 식재료 + + + + {excludedFoods.map((item) => ( + + + {item.food_name} + + + ))} + + + + )} + + {preferredIngredients.length > 0 && ( + + + + + + 선호 식재료 + + + + {preferredIngredients.map((item) => ( + + + {item.food_name} + + + ))} + + + + )} + + + + + 식단 기간 + + + + setSelectedPeriod("daily")} + activeOpacity={0.8} + > + {selectedPeriod === "daily" ? ( + + + + 1일 식단 + + + 오늘 하루 + + + ) : ( + + + 1일 식단 + 오늘 하루 + + )} + + + + Alert.alert( + "프리미엄 기능", + "7일 식단 추천은 프리미엄 전용입니다.\n업그레이드 하시겠습니까?", + [{ text: "취소", style: "cancel" }] + ) + } + activeOpacity={0.8} + > + + + + + + + 7일 식단 + + + + 프리미엄 + + + + + + + + + + + + 💡 AI가 사용자의 취향과 영양 균형을 분석하여 맞춤형 식단을 + 제안합니다. + + + + + + + + {loading ? ( + <> + + 생성 중... + + ) : ( + <> + + + {isTokenDepleted + ? "이번주 추천 완료" + : "오늘의 식단 추천받기"} + + + )} + + + + {isTokenDepleted && ( + + + + 이번 주 무료 추천을 모두 사용하였습니다.{"\n"} + 다음 주 월요일에 다시 추천 받을 수 있습니다. + + + )} + + + + ); + } + + return ( + + + + + + + + setScreen("input")} + style={styles.backButton} + > + + + + + 추천 결과 + + + + + + + {Array.from({ length: 7 }).map((_, index) => { + const isLocked = index > 0; + const isActive = currentDayTab === index; + + return ( + handleTabPress(index)} + activeOpacity={0.8} + style={styles.dayTabTouch} + > + {isActive ? ( + + + {index + 1}일차 + + + ) : ( + + {isLocked && ( + + )} + {index + 1}일차 + + )} + + ); + })} + + + + + + + + 오늘의 영양 섭취 + + + + {dailyNutrition.calories} kcal + + + + + + + 탄수화물 + + {dailyNutrition.carbs}g + + + + + 단백질 + + {dailyNutrition.protein}g + + + + + 지방 + + {dailyNutrition.fat}g + + + + + + + {recommendedMeals.map((meal, index) => ( + + + + + + + + {meal.mealTypeName === "아침" + ? "🌅" + : meal.mealTypeName === "점심" + ? "☀️" + : "🌙"} + + + + + {meal.mealTypeName} + + {meal.mealTypeName === "아침" + ? "07:00 - 09:00" + : meal.mealTypeName === "점심" + ? "12:00 - 14:00" + : "18:00 - 20:00"} + + + + + + {meal.totalCalories} + + kcal + + + + + {meal.foods.map((food, fIdx) => ( + + + + {food.foodName} {food.servingSize}g + + + ({food.calories}kcal) + + + + ))} + + + + ))} + + + + + + 이 식단 저장하기 + + + + setScreen("input")} + > + 다시 설정하기 + + + + + + + ); +}; + +const loadingStyles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + container: { + alignItems: "center", + justifyContent: "center", + width: "100%", + paddingHorizontal: 20, + }, + spinnerContainer: { + marginBottom: 40, + }, + spinnerOuter: { + width: 100, + height: 100, + borderRadius: 50, + borderWidth: 4, + borderColor: "rgba(227, 255, 124, 0.2)", + alignItems: "center", + justifyContent: "center", + }, + spinnerInner: { + width: 80, + height: 80, + borderRadius: 40, + borderWidth: 4, + borderColor: "#e3ff7c", + borderTopColor: "transparent", + borderRightColor: "transparent", + }, + textContainer: { + width: "100%", + alignItems: "center", + justifyContent: "center", + minHeight: 70, + }, + message: { + fontSize: 18, + fontWeight: "600", + color: "#ffffff", + textAlign: "center", + lineHeight: 28, + letterSpacing: 0.5, + }, + cancelButton: { + marginTop: 50, + borderRadius: 30, + overflow: "hidden", + }, + cancelButtonGradient: { + paddingVertical: 14, + paddingHorizontal: 28, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.2)", + borderRadius: 30, + }, + cancelText: { + color: "#ffffff", + fontSize: 15, + fontWeight: "600", + letterSpacing: 0.5, + }, +}); + +const mealsModalStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.8)", + justifyContent: "center", + alignItems: "center", + }, + container: { + width: "85%", + maxWidth: 400, + }, + content: { + borderRadius: 24, + padding: 28, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 12, + marginBottom: 12, + }, + title: { + fontSize: 24, + fontWeight: "800", + color: "#ffffff", + letterSpacing: 0.5, + }, + subtitle: { + fontSize: 15, + color: "#9ca3af", + textAlign: "center", + marginBottom: 28, + letterSpacing: 0.3, + }, + optionsContainer: { + gap: 12, + marginBottom: 24, + }, + optionButton: { + borderRadius: 16, + overflow: "hidden", + }, + optionGradient: { + borderRadius: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + optionContent: { + flexDirection: "row", + alignItems: "center", + padding: 18, + gap: 16, + }, + optionIconContainer: { + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + }, + optionTextContainer: { + flex: 1, + }, + optionNumber: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + letterSpacing: 0.3, + }, + optionNumberActive: { + color: "#111827", + }, + optionDesc: { + fontSize: 13, + color: "#9ca3af", + letterSpacing: 0.2, + }, + optionDescActive: { + color: "rgba(17,24,39,0.7)", + }, + closeButton: { + paddingVertical: 14, + alignItems: "center", + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + closeButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#9ca3af", + letterSpacing: 0.3, + }, +}); + +const styles = StyleSheet.create({ + container: { flex: 1 }, + safeArea: { flex: 1 }, + contentWrapper: { flex: 1 }, + contentContainer: { paddingHorizontal: 20, paddingBottom: 40 }, + resultContainer: { paddingHorizontal: 20, paddingBottom: 40, paddingTop: 10 }, + + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 20, + }, + backButton: { width: 40, height: 40 }, + iconButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255,255,255,0.1)", + alignItems: "center", + justifyContent: "center", + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + letterSpacing: 0.5, + }, + + freeUserBannerContainer: { + marginTop: 20, + marginBottom: 24, + borderRadius: 20, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 6, + }, + freeUserBanner: { padding: 20, gap: 12, borderRadius: 20 }, + freeUserBadge: { + alignSelf: "flex-start", + borderRadius: 12, + overflow: "hidden", + }, + freeUserBadgeGradient: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 12, + paddingVertical: 6, + }, + freeUserBadgeText: { fontSize: 12, fontWeight: "700", color: "#111827" }, + freeUserTitle: { fontSize: 20, fontWeight: "700", color: "#e3ff7c" }, + freeUserSubtitle: { fontSize: 14, color: "#9ca3af", lineHeight: 20 }, + premiumButton: { borderRadius: 12, overflow: "hidden", marginTop: 4 }, + premiumButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 14, + }, + premiumButtonText: { fontSize: 15, fontWeight: "700", color: "#111827" }, + + buttonRow: { + flexDirection: "row", + gap: 12, + marginBottom: 12, + }, + settingButtonHalf: { + flex: 1, + borderRadius: 16, + overflow: "hidden", + }, + settingButton: { + borderRadius: 16, + overflow: "hidden", + marginBottom: 12, + }, + settingButtonGradient: { + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.2)", + borderRadius: 16, + gap: 6, + }, + settingButtonGradientRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + paddingHorizontal: 24, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.2)", + borderRadius: 16, + }, + settingButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + }, + settingButtonTextSmall: { + fontSize: 14, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + textAlign: "center", + }, + badgeText: { + fontSize: 12, + fontWeight: "600", + color: "#9ca3af", + marginTop: 2, + }, + + excludedPreview: { + marginTop: 8, + marginBottom: 20, + }, + glassCard: { + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 16, + padding: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + previewHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 16, + gap: 8, + }, + excludedPreviewLabel: { + fontSize: 15, + color: "#9ca3af", + fontWeight: "600", + letterSpacing: 0.3, + }, + preferredPreviewLabel: { + fontSize: 15, + color: "#22c55e", + fontWeight: "600", + letterSpacing: 0.3, + }, + tagList: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + tag: { + borderRadius: 12, + overflow: "hidden", + }, + tagGradient: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 12, + }, + tagText: { + fontSize: 14, + color: "#ffffff", + fontWeight: "500", + }, + + section: { marginBottom: 24 }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginBottom: 12, + }, + sectionTitle: { fontSize: 18, fontWeight: "700", color: "#ffffff" }, + periodButtons: { flexDirection: "row", gap: 12 }, + periodButtonContainer: { flex: 1, borderRadius: 16, overflow: "hidden" }, + periodButton: { + backgroundColor: "rgba(255,255,255,0.05)", + padding: 20, + borderRadius: 16, + alignItems: "center", + gap: 8, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + periodButtonLocked: { opacity: 0.7 }, + periodButtonText: { fontSize: 16, fontWeight: "700", color: "#e3ff7c" }, + periodButtonTextActive: { fontSize: 16, fontWeight: "700", color: "#111827" }, + periodButtonTextLocked: { color: "#6b7280" }, + periodButtonSubtext: { fontSize: 12, color: "#6b7280", fontWeight: "500" }, + periodButtonSubtextActive: { + fontSize: 12, + color: "#374151", + fontWeight: "600", + }, + lockOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "center", + alignItems: "center", + zIndex: 1, + }, + premiumTag: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "rgba(227,255,124,0.1)", + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + premiumTagText: { fontSize: 10, fontWeight: "700", color: "#e3ff7c" }, + + infoBox: { marginBottom: 20, borderRadius: 14, overflow: "hidden" }, + infoBoxGradient: { + flexDirection: "row", + alignItems: "flex-start", + gap: 12, + padding: 16, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + borderRadius: 14, + }, + infoTextContainer: { flex: 1 }, + infoText: { + fontSize: 13, + color: "#e3ff7c", + lineHeight: 18, + fontWeight: "500", + }, + + generateButtonContainer: { + borderRadius: 16, + overflow: "hidden", + marginBottom: 12, + }, + generateButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 10, + paddingVertical: 18, + }, + generateButtonText: { fontSize: 17, fontWeight: "700", color: "#111827" }, + + limitInfoContainer: { + marginTop: 8, + alignItems: "center", + paddingVertical: 10, + }, + limitInfoText: { + fontSize: 13, + color: "#9ca3af", + textAlign: "center", + lineHeight: 20, + }, + + dayTabsWrapper: { marginBottom: 16 }, + dayTabsContent: { paddingHorizontal: 20, paddingBottom: 10, gap: 10 }, + dayTabTouch: { borderRadius: 20, overflow: "hidden" }, + dayTab: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 20, + backgroundColor: "rgba(255,255,255,0.05)", + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + }, + dayTabLocked: { + backgroundColor: "rgba(0,0,0,0.3)", + borderColor: "rgba(255,255,255,0.05)", + }, + dayTabActive: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 20, + }, + dayTabText: { fontSize: 14, color: "#9ca3af", fontWeight: "600" }, + dayTabTextActive: { fontSize: 14, color: "#111827", fontWeight: "700" }, + + nutritionCard: { borderRadius: 20, overflow: "hidden", marginBottom: 20 }, + nutritionCardGradient: { + padding: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 20, + }, + nutritionHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + nutritionTitle: { fontSize: 16, fontWeight: "700", color: "#fff" }, + totalCalBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#e3ff7c", + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + gap: 4, + }, + totalCalText: { fontSize: 14, fontWeight: "700", color: "#111827" }, + nutritionRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + nutrientItem: { alignItems: "center", flex: 1 }, + nutrientLabel: { fontSize: 12, color: "#9ca3af", marginBottom: 4 }, + nutrientValue: { fontSize: 16, fontWeight: "700", color: "#fff" }, + nutrientDivider: { + width: 1, + height: 30, + backgroundColor: "rgba(255,255,255,0.1)", + }, + + mealCardContainer: { marginBottom: 16, borderRadius: 18, overflow: "hidden" }, + mealCard: { + padding: 20, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 18, + }, + mealCardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + mealTitleRow: { flexDirection: "row", alignItems: "center", gap: 12 }, + mealIconContainer: { + width: 48, + height: 48, + borderRadius: 24, + overflow: "hidden", + }, + mealIcon: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + mealEmoji: { fontSize: 24 }, + mealName: { fontSize: 18, fontWeight: "700", color: "#ffffff" }, + mealTime: { fontSize: 12, color: "#6b7280", marginTop: 2, fontWeight: "500" }, + mealCaloriesContainer: { flexDirection: "row", alignItems: "baseline" }, + mealCalories: { fontSize: 20, fontWeight: "700", color: "#ffffff" }, + mealCaloriesUnit: { + fontSize: 14, + color: "#6b7280", + marginLeft: 2, + fontWeight: "600", + }, + foodsList: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + foodTag: { borderRadius: 14, overflow: "hidden" }, + foodTagItemGradient: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingVertical: 10, + paddingHorizontal: 14, + borderWidth: 1, + borderColor: "rgba(227,255,124,0.2)", + borderRadius: 14, + }, + foodName: { fontSize: 14, color: "#ffffff", fontWeight: "600" }, + foodCalories: { fontSize: 12, color: "#9ca3af", fontWeight: "500" }, + + resultActions: { gap: 12, marginTop: 10 }, + saveButtonContainer: { borderRadius: 16, overflow: "hidden" }, + saveButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 18, + }, + saveButtonText: { fontSize: 17, fontWeight: "700", color: "#111827" }, + retryButton: { alignItems: "center", padding: 16 }, + retryButtonText: { + color: "#9ca3af", + fontSize: 14, + textDecorationLine: "underline", + }, +}); + +export default TempMealRecommendScreen; diff --git a/src/services/userPreferencesAPI.ts b/src/services/userPreferencesAPI.ts index 920339e..5c8e2d9 100644 --- a/src/services/userPreferencesAPI.ts +++ b/src/services/userPreferencesAPI.ts @@ -1,22 +1,19 @@ // src/services/userPreferencesAPI.ts + import { requestAI } from "./apiConfig"; -import type { ExclusionResponse, DeleteExclusionResponse } from "../types"; +import type { + ExclusionResponse, + PreferenceResponse, + PreferenceDeleteResponse, +} from "../types"; export const userPreferencesAPI = { /** - * 📋 비선호(제외) 식단 목록 조회 - * GET /exclusions/{user_id} - * @param userId 사용자 ID (문자열, 예: "ehdrb") - * @returns 비선호 식단 목록 배열 - * - * 응답 예시: - * [ - * { id: 1, food_name: "굴비, 다랑어", reason: "taste" } - * ] + * 비선호 음식 목록 조회 */ - getExclusions: async (userId: string): Promise => { + getExclusions: async (userId: string) => { try { - console.log(`📋 비선호 식단 조회 요청 (User: ${userId})`); + console.log("🚫 비선호 음식 목록 조회:", userId); const response = await requestAI( `/exclusions/${userId}`, @@ -25,95 +22,171 @@ export const userPreferencesAPI = { } ); - console.log("✅ 비선호 식단 조회 성공:", response); + console.log("✅ 비선호 음식 조회 완료:", response.length, "개"); return response; } catch (error: any) { - console.error("❌ 비선호 식단 조회 실패:", error); - throw new Error( - error.message || "비선호 식단 목록을 불러오는데 실패했습니다." + if (error.status === 404) { + console.log("ℹ️ 비선호 음식 없음"); + return []; + } + console.error("❌ 비선호 음식 조회 실패:", error); + throw error; + } + }, + + /** + * 비선호 음식 추가 (단일) + */ + addExclusion: async (userId: string, foodName: string) => { + try { + console.log("🚫 비선호 음식 추가:", foodName); + + const response = await requestAI( + `/exclusions/${userId}?food_name=${encodeURIComponent(foodName)}`, + { + method: "POST", + } ); + + 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 - * @param userId 사용자 ID (문자열) - * @param foods 추가할 음식 이름 배열 (예: ["굴비", "다랑어"]) - * @returns 추가된 비선호 식단 정보 - * - * 응답 예시: - * { id: 1, food_name: "굴비, 다랑어", reason: "taste" } + * 비선호 음식 추가 (여러 개) - 하나씩 순차 추가 */ - addExclusions: async ( - userId: string, - foods: string[] - ): Promise => { + addExclusions: async (userId: string, foodNames: string[]) => { try { - console.log(`🚫 비선호 식단 추가 요청 (User: ${userId})`); - console.log("추가할 음식:", foods); - - // 쿼리 파라미터 생성 - const queryParams = new URLSearchParams(); - foods.forEach((food) => { - queryParams.append("food_name", food); - }); - queryParams.append("reason", "taste"); - - const queryString = queryParams.toString(); - const url = `/exclusions/${userId}?${queryString}`; - - // 📌 한글로 디코딩된 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` + console.log("🚫 비선호 음식 여러 개 추가:", foodNames); + + const results: ExclusionResponse[] = []; + + // 각 음식을 하나씩 추가 + for (const foodName of foodNames) { + console.log(` - "${foodName}" 추가 중...`); + const response = await requestAI( + `/exclusions/${userId}?food_name=${encodeURIComponent(foodName)}`, + { + method: "POST", + } + ); + results.push(response); + console.log(` ✅ "${foodName}" 추가 완료 (id: ${response.id})`); + } + + console.log("✅ 모든 비선호 음식 추가 완료:", results.length, "개"); + + // 마지막 결과 반환 (DislikedFoodsModal 호환성) + return results[results.length - 1]; + } catch (error: any) { + console.error("❌ 비선호 음식 추가 실패:", error); + throw new Error(error.message || "비선호 음식 추가에 실패했습니다."); + } + }, + + /** + * 비선호 음식 삭제 + */ + deleteExclusion: async (exclusionId: number) => { + try { + console.log("🗑️ 비선호 음식 삭제:", exclusionId); + + const response = await requestAI<{ status: string }>( + `/exclusions/${exclusionId}`, + { + method: "DELETE", + } ); - const response = await requestAI(url, { - method: "POST", - }); + console.log("✅ 비선호 음식 삭제 완료"); + return response; + } catch (error: any) { + console.error("❌ 비선호 음식 삭제 실패:", error); + throw new Error(error.message || "비선호 음식 삭제에 실패했습니다."); + } + }, + + /** + * 선호하는 음식 목록 조회 + */ + getPreferences: async (userId: string) => { + try { + console.log("💚 선호 음식 목록 조회:", userId); - console.log("✅ 비선호 식단 추가 성공:", response); + const response = await requestAI( + `/preferences/${userId}`, + { + method: "GET", + } + ); + + console.log("✅ 선호 음식 조회 완료:", response.length, "개"); return response; } catch (error: any) { - console.error("❌ 비선호 식단 추가 실패:", error); - throw new Error(error.message || "비선호 식단 추가에 실패했습니다."); + if (error.status === 404) { + console.log("ℹ️ 선호 음식 없음"); + return []; + } + console.error("❌ 선호 음식 조회 실패:", error); + throw error; } }, /** - * 🗑️ 비선호(제외) 식단 삭제 - * DELETE /exclusions/{exclusion_id} - * @param exclusionId 비선호 식단 저장한 식단의 id (조회 시 받은 id 값) - * @returns 삭제 결과 - * - * 응답 예시: - * { status: "deleted" } + * 선호하는 음식 추가 (여러 개) - 하나씩 순차 추가 */ - deleteExclusion: async ( - exclusionId: number - ): Promise => { + addPreferences: async (userId: string, foodNames: string[]) => { try { - console.log(`🗑️ 비선호 식단 삭제 요청 (ID: ${exclusionId})`); + console.log("💚 선호 음식 여러 개 추가:", foodNames); - const response = await requestAI( - `/exclusions/${exclusionId}`, + const results: PreferenceResponse[] = []; + + // 각 음식을 하나씩 추가 + for (const foodName of foodNames) { + console.log(` - "${foodName}" 추가 중...`); + const response = await requestAI( + `/preferences/${userId}?food_name=${encodeURIComponent(foodName)}`, + { + method: "POST", + } + ); + results.push(response); + console.log(` ✅ "${foodName}" 추가 완료 (id: ${response.id})`); + } + + console.log("✅ 모든 선호 음식 추가 완료:", results.length, "개"); + + // 마지막 결과 반환 + return results[results.length - 1]; + } catch (error: any) { + console.error("❌ 선호 음식 추가 실패:", error); + throw new Error(error.message || "선호 음식 추가에 실패했습니다."); + } + }, + + /** + * 선호하는 음식 삭제 + */ + deletePreference: async (preferenceId: number) => { + try { + console.log("🗑️ 선호 음식 삭제:", preferenceId); + + const response = await requestAI( + `/preferences/${preferenceId}`, { method: "DELETE", } ); - console.log("✅ 비선호 식단 삭제 성공:", response); + console.log("✅ 선호 음식 삭제 완료:", response); return response; } catch (error: any) { - console.error(`❌ 비선호 식단 삭제 실패 (ID: ${exclusionId}):`, error); - throw new Error(error.message || "비선호 식단 삭제에 실패했습니다."); + console.error("❌ 선호 음식 삭제 실패:", error); + throw new Error(error.message || "선호 음식 삭제에 실패했습니다."); } }, }; diff --git a/src/types/index.ts b/src/types/index.ts index 3e2fd07..7b31fc7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,282 +1,293 @@ -// 공통 타입 정의 -export interface User { - id: string; - email: string; - name: string; - phone?: string; - birthDate?: string; -} - -export interface Food { - id: number; - name: string; - calories: number; - carbs: number; - protein: number; - fat: number; - weight?: number; - recordCount?: string; -} - -// 음식 검색 API 응답 타입 -export interface SearchFoodResponse { - id: number; - name: string; - calories: number; - carbs: number; - protein: number; - fat: number; - fiber: number; - sugar: number; - sodium: number; - weight: number; - glycemic_index: number; - processing_level: number; - company: string; -} - -// 직접 음식 입력 API 요청 타입 -export interface AddManualFoodRequest { - name: string; - weight: number; - calories: number; - carbs: number; - protein: number; - fat: number; -} - -export interface Meal { - id: string; - date: string; - type: "breakfast" | "lunch" | "dinner" | "snack"; - foods: Food[]; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; -} - -export interface Exercise { - id: string; - name: string; - icon?: string; - detail?: string; - calories?: number; - duration?: number; -} - -export interface Routine { - id: number; - date: string; - level?: string; - weakParts?: string[]; - targetParts?: string[]; - routine?: any[][]; -} - -export interface MealPlan { - id: number; - date: string; - meals: any[]; -} - -export interface DailyMealFood { - id: number; - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; - sodium: number; - cholesterol: number; - sugar: number; - fiber: number; - imageUrl: string; - aiConfidenceScore: number; -} - -export interface DailyMeal { - id: number; - mealDate: string; - mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; - mealTypeName: string; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - foods: DailyMealFood[]; - memo?: string; - createdAt: string; -} - -export interface DailyMealsResponse { - date: string; - meals: DailyMeal[]; - dailyTotalCalories: number; - dailyTotalCarbs: number; - dailyTotalProtein: number; - dailyTotalFat: number; -} - -// 식사 추가 요청 타입 -export interface AddMealFoodRequest { - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; - sodium?: number; - cholesterol?: number; - sugar?: number; - fiber?: number; - imageUrl?: string; - aiConfidenceScore?: number; - id?: number; // food_id (검색 또는 직접 입력에서 받은 음식 ID) - food_id?: number; // food_id (API 호출 시 사용) -} - -export interface AddMealRequest { - mealDate: string; // yyyy-MM-dd 형식 - mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; - foods: AddMealFoodRequest[]; - memo?: string; - timeTaken?: string; // ISO 8601 형식 (YYYY-MM-DDTHH:mm:ss.sssZ) -} - -export interface AddMealResponse { - success: boolean; - message: string; - meal: DailyMeal; -} - -// 일일 운동 진행률 타입 -export interface DailyProgressWeekItem { - date: string; // yyyy-MM-dd 형식 - exerciseRate: number; // 운동 달성률 (0~100) - totalCalorie: number; // 총 칼로리 - mealCount?: number; // 식사 횟수 - exerciseCount?: number; // 운동 종목 수 - exerciseTime?: number; // 운동 시간 (초) -} - -// 영양 목표 타입 -export interface NutritionGoal { - id: number; - targetCalories: number; - targetCarbs: number; - targetProtein: number; - targetFat: number; - goalType: string; - goalTypeDescription: string; -} - -// 영양 목표 설정 요청 타입 -export interface SetNutritionGoalRequest { - targetCalories: number; - targetCarbs: number; - targetProtein: number; - targetFat: number; - goalType?: "AUTO" | "MANUAL"; - date?: string; // YYYY-MM-DD 형식, 지정 날짜부터 말일까지 일괄 적용 -} - -// 영양 목표 설정 응답 타입 -export interface SetNutritionGoalResponse { - success: boolean; - message?: string; - goal?: NutritionGoal; - data?: NutritionGoal; -} -export interface RecommendedNutritionGoal { - tdee: number; - goal_kcal: number; - protein_g: number; - carbs_g: number; - fat_g: number; - ratio: { - protein: number; - carbs: number; - fat: number; - }; -} -export interface UserPreferencesResponse { - success?: boolean; - message?: string; - userId?: number; - dislikedFoods: string[]; - preferredFoods: 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: { - name: string; - experienceLevel: string; - experienceLevelName: string; - healthGoal: string; - healthGoalName: string; - currentWeight: number; - targetWeight: number; - workoutDaysPerWeek: string; - }; - todayExercise: { - date: string; - completed: boolean; - totalDurationMinutes: number; - totalCaloriesBurned: number; - exerciseCount: number; - mainCategories: string[]; - message: string; - }; - todayMeal: { - date: string; - totalCalories: number; - targetCalories: number; - calorieAchievementRate: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - mealCount: number; - message: string; - }; - latestInBody: { - measurementDate: string; - weight: number; - skeletalMuscleMass: number; - bodyFatPercentage: number; - bmi: number; - weightChange: number; - achievementBadge: string; - message: string; - }; - weeklySummary: { - weekStartDate: string; - weekEndDate: string; - weeklyExerciseDays: number; - weeklyTotalDurationMinutes: number; - weeklyAvgCalories: number; - exerciseChangeRate: number; - calorieChangeRate: number; - weeklyGoalAchievementRate: number; - message: string; - }; - aiChatbotAvailable: boolean; -} +// 공통 타입 정의 +export interface User { + id: string; + email: string; + name: string; + phone?: string; + birthDate?: string; +} + +export interface Food { + id: number; + name: string; + calories: number; + carbs: number; + protein: number; + fat: number; + weight?: number; + recordCount?: string; +} + +// 음식 검색 API 응답 타입 +export interface SearchFoodResponse { + id: number; + name: string; + calories: number; + carbs: number; + protein: number; + fat: number; + fiber: number; + sugar: number; + sodium: number; + weight: number; + glycemic_index: number; + processing_level: number; + company: string; +} + +// 직접 음식 입력 API 요청 타입 +export interface AddManualFoodRequest { + name: string; + weight: number; + calories: number; + carbs: number; + protein: number; + fat: number; +} + +export interface Meal { + id: string; + date: string; + type: "breakfast" | "lunch" | "dinner" | "snack"; + foods: Food[]; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; +} + +export interface Exercise { + id: string; + name: string; + icon?: string; + detail?: string; + calories?: number; + duration?: number; +} + +export interface Routine { + id: number; + date: string; + level?: string; + weakParts?: string[]; + targetParts?: string[]; + routine?: any[][]; +} + +export interface MealPlan { + id: number; + date: string; + meals: any[]; +} + +export interface DailyMealFood { + id: number; + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; + sodium: number; + cholesterol: number; + sugar: number; + fiber: number; + imageUrl: string; + aiConfidenceScore: number; +} + +export interface DailyMeal { + id: number; + mealDate: string; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; + mealTypeName: string; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + foods: DailyMealFood[]; + memo?: string; + createdAt: string; +} + +export interface DailyMealsResponse { + date: string; + meals: DailyMeal[]; + dailyTotalCalories: number; + dailyTotalCarbs: number; + dailyTotalProtein: number; + dailyTotalFat: number; +} + +// 식사 추가 요청 타입 +export interface AddMealFoodRequest { + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; + sodium?: number; + cholesterol?: number; + sugar?: number; + fiber?: number; + imageUrl?: string; + aiConfidenceScore?: number; + id?: number; // food_id (검색 또는 직접 입력에서 받은 음식 ID) + food_id?: number; // food_id (API 호출 시 사용) +} + +export interface AddMealRequest { + mealDate: string; // yyyy-MM-dd 형식 + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; + foods: AddMealFoodRequest[]; + memo?: string; + timeTaken?: string; // ISO 8601 형식 (YYYY-MM-DDTHH:mm:ss.sssZ) +} + +export interface AddMealResponse { + success: boolean; + message: string; + meal: DailyMeal; +} + +// 일일 운동 진행률 타입 +export interface DailyProgressWeekItem { + date: string; // yyyy-MM-dd 형식 + exerciseRate: number; // 운동 달성률 (0~100) + totalCalorie: number; // 총 칼로리 + mealCount?: number; // 식사 횟수 + exerciseCount?: number; // 운동 종목 수 + exerciseTime?: number; // 운동 시간 (초) +} + +// 영양 목표 타입 +export interface NutritionGoal { + id: number; + targetCalories: number; + targetCarbs: number; + targetProtein: number; + targetFat: number; + goalType: string; + goalTypeDescription: string; +} + +// 영양 목표 설정 요청 타입 +export interface SetNutritionGoalRequest { + targetCalories: number; + targetCarbs: number; + targetProtein: number; + targetFat: number; + goalType?: "AUTO" | "MANUAL"; + date?: string; // YYYY-MM-DD 형식, 지정 날짜부터 말일까지 일괄 적용 +} + +// 영양 목표 설정 응답 타입 +export interface SetNutritionGoalResponse { + success: boolean; + message?: string; + goal?: NutritionGoal; + data?: NutritionGoal; +} +export interface RecommendedNutritionGoal { + tdee: number; + goal_kcal: number; + protein_g: number; + carbs_g: number; + fat_g: number; + ratio: { + protein: number; + carbs: number; + fat: number; + }; +} +export interface UserPreferencesResponse { + success?: boolean; + message?: string; + userId?: number; + dislikedFoods: string[]; + preferredFoods: 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: { + name: string; + experienceLevel: string; + experienceLevelName: string; + healthGoal: string; + healthGoalName: string; + currentWeight: number; + targetWeight: number; + workoutDaysPerWeek: string; + }; + todayExercise: { + date: string; + completed: boolean; + totalDurationMinutes: number; + totalCaloriesBurned: number; + exerciseCount: number; + mainCategories: string[]; + message: string; + }; + todayMeal: { + date: string; + totalCalories: number; + targetCalories: number; + calorieAchievementRate: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + mealCount: number; + message: string; + }; + latestInBody: { + measurementDate: string; + weight: number; + skeletalMuscleMass: number; + bodyFatPercentage: number; + bmi: number; + weightChange: number; + achievementBadge: string; + message: string; + }; + weeklySummary: { + weekStartDate: string; + weekEndDate: string; + weeklyExerciseDays: number; + weeklyTotalDurationMinutes: number; + weeklyAvgCalories: number; + exerciseChangeRate: number; + calorieChangeRate: number; + weeklyGoalAchievementRate: number; + message: string; + }; + aiChatbotAvailable: boolean; +} +export interface PreferenceResponse { + id: number; + food_name: string; + score: number; + source: string; +} + +export interface PreferenceDeleteResponse { + status: "deleted"; +} diff --git a/src/utils/inbodyApi.ts b/src/utils/inbodyApi.ts index cff61de..357420c 100644 --- a/src/utils/inbodyApi.ts +++ b/src/utils/inbodyApi.ts @@ -1,661 +1,661 @@ -import axios from "axios"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { ACCESS_TOKEN_KEY, API_BASE_URL } from "../services/apiConfig"; - -const INBODY_API_URL = `${API_BASE_URL}/api/inbody`; - -export interface InBodyPayload { - measurementDate?: string; // "2025-08-04" - weight?: number; - muscleMass?: number; - bodyFatMass?: number; - skeletalMuscleMass?: number; - bodyFatPercentage?: number; - leftArmMuscle?: number; - rightArmMuscle?: number; - trunkMuscle?: number; - leftLegMuscle?: number; - rightLegMuscle?: number; - leftArmFat?: number; - rightArmFat?: number; - trunkFat?: number; - leftLegFat?: number; - rightLegFat?: number; - totalBodyWater?: number; - protein?: number; - mineral?: number; - bmi?: number; - bodyFatPercentageStandard?: number; - obesityDegree?: number; - visceralFatLevel?: number; - basalMetabolicRate?: number; - achievementBadge?: string; -} - -export interface InBodyResponse { - success: boolean; - message: string; - inBody?: any; -} - -/** - * 인바디 정보 등록 - */ -export const postInBody = async ( - payload: InBodyPayload -): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - const response = await axios.post(INBODY_API_URL, payload, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - console.error("[INBODY][POST] API 에러:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - }); - } else { - console.error("[INBODY][POST] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 인바디 목록 조회 (사용자의 모든 인바디 기록) - */ -export const getInBodyList = async (): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - const response = await axios.get(INBODY_API_URL, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; - - console.error("[INBODY][GET] API 에러:", { - message: error.message, - status, - statusText: error.response?.statusText, - data: errorData, - }); - - // 서버 내부 오류(COMMON_002, 500) 등은 "목록 없음"으로 처리해서 - // 화면이 크래시되지 않고 그냥 인바디 기록이 없는 것처럼 동작하게 한다. - if (status === 500) { - return null; - } - } else { - console.error("[INBODY][GET] 예상치 못한 에러:", error); - } - // 기타 에러는 상위에서 처리 - throw error; - } -}; - -/** - * 최신 인바디 기록 조회 (로그인 사용자 기준) - */ -export const getLatestInBody = async (): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - const url = `${INBODY_API_URL}/latest`; - - console.log("[INBODY][GET LATEST] API 요청:", { - url, - hasToken: !!token, - }); - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - console.log("[INBODY][GET LATEST] API 응답 성공:", { - status: response.status, - hasData: !!response.data, - dataKeys: response.data ? Object.keys(response.data) : [], - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; - - // 400 또는 404는 데이터 없음으로 처리 - if (status === 400 || status === 404) { - console.log("[INBODY][GET LATEST] 데이터 없음:", { - status, - errorCode: errorData?.code, - errorMessage: errorData?.message, - data: errorData, - }); - return null; - } - - console.error("[INBODY][GET LATEST] API 에러:", { - message: error.message, - status, - statusText: error.response?.statusText, - errorCode: errorData?.code, - errorMessage: errorData?.message, - data: errorData, - requestUrl: error.config?.url, - requestMethod: error.config?.method, - requestHeaders: error.config?.headers, - }); - } else { - console.error("[INBODY][GET LATEST] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 인바디 상세 조회 (ID 기반) - */ -export const getInBodyById = async ( - inBodyId: number | string -): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - const url = `${INBODY_API_URL}/${inBodyId}`; - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - console.error("[INBODY][GET BY ID] API 에러:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - }); - } else { - console.error("[INBODY][GET BY ID] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 인바디 정보 수정 - * @param inBodyId 인바디 기록 ID - * @param payload 수정할 데이터 (선택적 필드) - */ -export const patchInBody = async ( - inBodyId: number | string, - payload: Partial -): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - const url = `${INBODY_API_URL}/${inBodyId}`; - - const response = await axios.patch(url, payload, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - console.error("[INBODY][PATCH] API 에러:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - }); - } else { - console.error("[INBODY][PATCH] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 특정 날짜의 인바디 기록 조회 (경로 파라미터 사용) - * @param date 날짜 (YYYY-MM-DD 형식) - */ -export const getInBodyByDatePath = async (date: string): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - // 날짜 형식 정규화 (YYYY-MM-DD) - const normalizedDate = date.replace(/\./g, "-").split("T")[0].split(" ")[0]; - - const url = `${INBODY_API_URL}/date/${normalizedDate}`; - - console.log("[INBODY][GET BY DATE PATH] API 요청:", { - url, - date: normalizedDate, - hasToken: !!token, - }); - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - console.log("[INBODY][GET BY DATE PATH] API 응답 성공:", { - status: response.status, - hasData: !!response.data, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; - - // 400 또는 404는 데이터 없음으로 처리 - if (status === 400 || status === 404) { - console.log("[INBODY][GET BY DATE PATH] 데이터 없음:", { - status, - date, - errorCode: errorData?.code, - errorMessage: errorData?.message, - }); - return null; - } - - console.error("[INBODY][GET BY DATE PATH] API 에러:", { - message: error.message, - status, - statusText: error.response?.statusText, - errorCode: errorData?.code, - errorMessage: errorData?.message, - date, - }); - } else { - console.error("[INBODY][GET BY DATE PATH] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 특정 날짜의 인바디 기록 조회 (쿼리 파라미터 사용, 기존 함수) - * @param date 날짜 (YYYY-MM-DD 또는 YYYY.MM.DD 형식) - */ -export const getInBodyByDate = async (date: string): Promise => { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - const sanitized = (date || "").trim(); - if (!sanitized) { - throw new Error("조회할 날짜가 비어 있습니다."); - } - - const compact = sanitized.replace(/[^\d]/g, ""); - const hyphenated = - compact.length === 8 - ? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6)}` - : sanitized.replace(/\./g, "-"); - const dotted = - compact.length === 8 - ? `${compact.slice(0, 4)}.${compact.slice(4, 6)}.${compact.slice(6)}` - : hyphenated.replace(/-/g, "."); - - const withTimeHyphen = - hyphenated && !hyphenated.includes("T") - ? `${hyphenated}T00:00:00` - : hyphenated; - const withTimeSpace = - hyphenated && !hyphenated.includes(" ") - ? `${hyphenated} 00:00:00` - : hyphenated; - - const dateFormats = Array.from( - new Set( - [ - sanitized, - hyphenated, - dotted, - sanitized.replace(/\s+/g, ""), - hyphenated.replace(/\s+/g, ""), - dotted.replace(/\s+/g, ""), - compact.length === 8 ? compact : null, - withTimeHyphen, - withTimeSpace, - ].filter(Boolean) - ) - ); - - for (const formattedDate of dateFormats) { - try { - const url = `${INBODY_API_URL}?date=${encodeURIComponent(formattedDate)}`; - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - return response.data; - } catch (error: any) { - const isLastAttempt = - dateFormats[dateFormats.length - 1] === formattedDate; - if (!isLastAttempt) { - console.warn( - `[INBODY][GET BY DATE] 날짜 형식 ${formattedDate} 실패, 다음 형식 시도...` - ); - continue; - } - - // 모든 형식 실패 시 에러 로그 - if (axios.isAxiosError(error)) { - console.error("[INBODY][GET BY DATE] API 에러:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - }); - } else { - console.error("[INBODY][GET BY DATE] 예상치 못한 에러:", error); - } - throw error; - } - } - - throw new Error("모든 날짜 형식 시도 실패"); -}; - -/** - * 인바디 이미지 업로드 - * @param file 이미지 파일 (expo-image-picker의 Asset 타입) - */ -export interface InBodyUploadResponse { - success: boolean; - message: string; - imageUrl?: string; - draftData?: InBodyPayload; -} - -export const uploadInBodyImage = async ( - file: any -): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - const url = `${INBODY_API_URL}/upload`; - - console.log("[INBODY][UPLOAD] 업로드 시작:", { - url, - fileUri: file.uri, - fileName: file.fileName, - fileType: file.type, - fileSize: file.fileSize, - hasToken: !!token, - }); - - // FormData 생성 - const formData = new FormData(); - - // 파일 추가 - const fileData = { - uri: file.uri, - type: file.type || "image/jpeg", - name: file.fileName || "inbody.jpg", - } as any; - - formData.append("file", fileData); - - console.log("[INBODY][UPLOAD] FormData 생성 완료:", { - fileUri: fileData.uri, - fileType: fileData.type, - fileName: fileData.name, - }); - - console.log("[INBODY][UPLOAD] API 요청 전송 중..."); - const response = await axios.post(url, formData, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "multipart/form-data", - Accept: "application/json", - }, - }); - - console.log("[INBODY][UPLOAD] API 응답 받음:", { - status: response.status, - success: response.data?.success, - message: response.data?.message, - hasImageUrl: !!response.data?.imageUrl, - hasDraftData: !!response.data?.draftData, - }); - - return response.data; - } catch (error: any) { - if (axios.isAxiosError(error)) { - console.error("[INBODY][UPLOAD] API 에러:", { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data, - url: error.config?.url, - }); - } else { - console.error("[INBODY][UPLOAD] 예상치 못한 에러:", error); - } - throw error; - } -}; - -/** - * 인바디 AI 코멘트 조회 - */ -export interface InBodyCommentRequest { - user_id: string; - period: string; // "2025-01-04 ~ 2025-07-04" - latest: { - date: string; // "2025-07-04" - weight: number; - body_fat_percentage: number; - skeletal_muscle_mass: number; - }; - trend: { - weight: "UP" | "DOWN" | "SAME"; - body_fat_percentage: "UP" | "DOWN" | "SAME"; - skeletal_muscle_mass: "UP" | "DOWN" | "SAME"; - }; -} - -export interface InBodyCommentResponse { - comment: string; -} - -export const getInBodyComment = async ( - request: InBodyCommentRequest -): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - // GET 요청을 위한 쿼리 파라미터 구성 - const params = new URLSearchParams({ - user_id: request.user_id, - period: request.period, - date: request.latest.date, - weight: request.latest.weight.toString(), - body_fat_percentage: request.latest.body_fat_percentage.toString(), - skeletal_muscle_mass: request.latest.skeletal_muscle_mass.toString(), - weight_trend: request.trend.weight, - body_fat_percentage_trend: request.trend.body_fat_percentage, - skeletal_muscle_mass_trend: request.trend.skeletal_muscle_mass, - }); - - const url = `${INBODY_API_URL}/comment/daily/weight?${params.toString()}`; - - console.log("[INBODY][COMMENT] AI 코멘트 조회 요청:", { - url, - request, - hasToken: !!token, - }); - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - // 🔍 응답 전체 데이터 로깅 추가 - console.log("[INBODY][COMMENT] AI 코멘트 조회 응답 전체:", JSON.stringify(response.data, null, 2)); - console.log("[INBODY][COMMENT] response.data 타입:", typeof response.data); - console.log("[INBODY][COMMENT] response.data 키들:", response.data && typeof response.data === 'object' ? Object.keys(response.data) : 'N/A'); - console.log("[INBODY][COMMENT] response.data.comment:", response.data?.comment); - console.log("[INBODY][COMMENT] response.data.comments:", response.data?.comments); - console.log("[INBODY][COMMENT] response.data.message:", response.data?.message); - - // 🔧 comments (복수형) 필드도 확인 - let comment = response.data?.comments || response.data?.comment || response.data?.message || ""; - - // comments가 배열인 경우 랜덤으로 하나 선택 - if (Array.isArray(comment) && comment.length > 0) { - const randomIndex = Math.floor(Math.random() * comment.length); - const selectedComment = comment[randomIndex]; - console.log("[INBODY][COMMENT] 배열에서 랜덤 선택:", { - totalComments: comment.length, - selectedIndex: randomIndex, - selectedComment: selectedComment, - }); - comment = selectedComment; - } - - console.log("[INBODY][COMMENT] 추출된 comment:", comment); - console.log("[INBODY][COMMENT] AI 코멘트 조회 응답:", { - status: response.status, - comment: comment, - }); - - return { comment: typeof comment === 'string' ? comment : "" }; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; - - console.error("[INBODY][COMMENT] API 에러:", { - message: error.message, - status, - statusText: error.response?.statusText, - data: errorData, - requestUrl: error.config?.url, - requestData: error.config?.data, - }); - - // 404나 400은 코멘트 없음으로 처리 - if (status === 404 || status === 400) { - console.log("[INBODY][COMMENT] 코멘트 없음 (404/400)"); - return { comment: "" }; - } - - // 500 에러는 서버 내부 오류이므로 빈 코멘트 반환 (사용자에게는 표시 안 함) - if (status === 500) { - console.warn( - "[INBODY][COMMENT] 서버 내부 오류 (500) - 코멘트 표시 안 함" - ); - return { comment: "" }; - } - } else { - console.error("[INBODY][COMMENT] 예상치 못한 에러:", error); - } - return { comment: "" }; - } -}; - -/** - * 인바디 히스토리 조회 - */ -export interface InBodyHistoryItem { - id: number; - measurementDate: string; - weight: number; - skeletalMuscleMass: number; - bodyFatMass: number; - bodyFatPercentage: number; - bmi: number; - score: number; -} - -export const getInBodyHistory = async (): Promise => { - try { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - const url = `${INBODY_API_URL}/history`; - - console.log("[INBODY][HISTORY] API 요청:", { - url, - hasToken: !!token, - }); - - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${token || ""}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - - console.log("[INBODY][HISTORY] API 응답:", { - status: response.status, - dataCount: Array.isArray(response.data) ? response.data.length : 0, - }); - - return Array.isArray(response.data) ? response.data : []; - } catch (error: any) { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const errorData = error.response?.data; - - console.error("[INBODY][HISTORY] API 에러:", { - message: error.message, - status, - statusText: error.response?.statusText, - data: errorData, - }); - - // 404나 400은 빈 배열 반환 - if (status === 404 || status === 400) { - console.log("[INBODY][HISTORY] 데이터 없음 (404/400)"); - return []; - } - } else { - console.error("[INBODY][HISTORY] 예상치 못한 에러:", error); - } - return []; - } -}; +import axios from "axios"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { ACCESS_TOKEN_KEY, API_BASE_URL } from "../services/apiConfig"; + +const INBODY_API_URL = `${API_BASE_URL}/api/inbody`; + +export interface InBodyPayload { + measurementDate?: string; // "2025-08-04" + weight?: number; + muscleMass?: number; + bodyFatMass?: number; + skeletalMuscleMass?: number; + bodyFatPercentage?: number; + leftArmMuscle?: number; + rightArmMuscle?: number; + trunkMuscle?: number; + leftLegMuscle?: number; + rightLegMuscle?: number; + leftArmFat?: number; + rightArmFat?: number; + trunkFat?: number; + leftLegFat?: number; + rightLegFat?: number; + totalBodyWater?: number; + protein?: number; + mineral?: number; + bmi?: number; + bodyFatPercentageStandard?: number; + obesityDegree?: number; + visceralFatLevel?: number; + basalMetabolicRate?: number; + achievementBadge?: string; +} + +export interface InBodyResponse { + success: boolean; + message: string; + inBody?: any; +} + +/** + * 인바디 정보 등록 + */ +export const postInBody = async ( + payload: InBodyPayload +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + const response = await axios.post(INBODY_API_URL, payload, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error("[INBODY][POST] API 에러:", { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + }); + } else { + console.error("[INBODY][POST] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 인바디 목록 조회 (사용자의 모든 인바디 기록) + */ +export const getInBodyList = async (): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + const response = await axios.get(INBODY_API_URL, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + console.error("[INBODY][GET] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + data: errorData, + }); + + // 서버 내부 오류(COMMON_002, 500) 등은 "목록 없음"으로 처리해서 + // 화면이 크래시되지 않고 그냥 인바디 기록이 없는 것처럼 동작하게 한다. + if (status === 500) { + return null; + } + } else { + console.error("[INBODY][GET] 예상치 못한 에러:", error); + } + // 기타 에러는 상위에서 처리 + throw error; + } +}; + +/** + * 최신 인바디 기록 조회 (로그인 사용자 기준) + */ +export const getLatestInBody = async (): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/latest`; + + console.log("[INBODY][GET LATEST] API 요청:", { + url, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + console.log("[INBODY][GET LATEST] API 응답 성공:", { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [], + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + // 400 또는 404는 데이터 없음으로 처리 + if (status === 400 || status === 404) { + console.log("[INBODY][GET LATEST] 데이터 없음:", { + status, + errorCode: errorData?.code, + errorMessage: errorData?.message, + data: errorData, + }); + return null; + } + + console.error("[INBODY][GET LATEST] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + errorCode: errorData?.code, + errorMessage: errorData?.message, + data: errorData, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + requestHeaders: error.config?.headers, + }); + } else { + console.error("[INBODY][GET LATEST] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 인바디 상세 조회 (ID 기반) + */ +export const getInBodyById = async ( + inBodyId: number | string +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/${inBodyId}`; + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error("[INBODY][GET BY ID] API 에러:", { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + }); + } else { + console.error("[INBODY][GET BY ID] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 인바디 정보 수정 + * @param inBodyId 인바디 기록 ID + * @param payload 수정할 데이터 (선택적 필드) + */ +export const patchInBody = async ( + inBodyId: number | string, + payload: Partial +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/${inBodyId}`; + + const response = await axios.patch(url, payload, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error("[INBODY][PATCH] API 에러:", { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + }); + } else { + console.error("[INBODY][PATCH] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 특정 날짜의 인바디 기록 조회 (경로 파라미터 사용) + * @param date 날짜 (YYYY-MM-DD 형식) + */ +export const getInBodyByDatePath = async (date: string): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + // 날짜 형식 정규화 (YYYY-MM-DD) + const normalizedDate = date.replace(/\./g, "-").split("T")[0].split(" ")[0]; + + const url = `${INBODY_API_URL}/date/${normalizedDate}`; + + console.log("[INBODY][GET BY DATE PATH] API 요청:", { + url, + date: normalizedDate, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + console.log("[INBODY][GET BY DATE PATH] API 응답 성공:", { + status: response.status, + hasData: !!response.data, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + // 400 또는 404는 데이터 없음으로 처리 + if (status === 400 || status === 404) { + console.log("[INBODY][GET BY DATE PATH] 데이터 없음:", { + status, + date, + errorCode: errorData?.code, + errorMessage: errorData?.message, + }); + return null; + } + + console.error("[INBODY][GET BY DATE PATH] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + errorCode: errorData?.code, + errorMessage: errorData?.message, + date, + }); + } else { + console.error("[INBODY][GET BY DATE PATH] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 특정 날짜의 인바디 기록 조회 (쿼리 파라미터 사용, 기존 함수) + * @param date 날짜 (YYYY-MM-DD 또는 YYYY.MM.DD 형식) + */ +export const getInBodyByDate = async (date: string): Promise => { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + const sanitized = (date || "").trim(); + if (!sanitized) { + throw new Error("조회할 날짜가 비어 있습니다."); + } + + const compact = sanitized.replace(/[^\d]/g, ""); + const hyphenated = + compact.length === 8 + ? `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6)}` + : sanitized.replace(/\./g, "-"); + const dotted = + compact.length === 8 + ? `${compact.slice(0, 4)}.${compact.slice(4, 6)}.${compact.slice(6)}` + : hyphenated.replace(/-/g, "."); + + const withTimeHyphen = + hyphenated && !hyphenated.includes("T") + ? `${hyphenated}T00:00:00` + : hyphenated; + const withTimeSpace = + hyphenated && !hyphenated.includes(" ") + ? `${hyphenated} 00:00:00` + : hyphenated; + + const dateFormats = Array.from( + new Set( + [ + sanitized, + hyphenated, + dotted, + sanitized.replace(/\s+/g, ""), + hyphenated.replace(/\s+/g, ""), + dotted.replace(/\s+/g, ""), + compact.length === 8 ? compact : null, + withTimeHyphen, + withTimeSpace, + ].filter(Boolean) + ) + ); + + for (const formattedDate of dateFormats) { + try { + const url = `${INBODY_API_URL}?date=${encodeURIComponent(formattedDate)}`; + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + return response.data; + } catch (error: any) { + const isLastAttempt = + dateFormats[dateFormats.length - 1] === formattedDate; + if (!isLastAttempt) { + console.warn( + `[INBODY][GET BY DATE] 날짜 형식 ${formattedDate} 실패, 다음 형식 시도...` + ); + continue; + } + + // 모든 형식 실패 시 에러 로그 + if (axios.isAxiosError(error)) { + console.error("[INBODY][GET BY DATE] API 에러:", { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + }); + } else { + console.error("[INBODY][GET BY DATE] 예상치 못한 에러:", error); + } + throw error; + } + } + + throw new Error("모든 날짜 형식 시도 실패"); +}; + +/** + * 인바디 이미지 업로드 + * @param file 이미지 파일 (expo-image-picker의 Asset 타입) + */ +export interface InBodyUploadResponse { + success: boolean; + message: string; + imageUrl?: string; + draftData?: InBodyPayload; +} + +export const uploadInBodyImage = async ( + file: any +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/upload`; + + console.log("[INBODY][UPLOAD] 업로드 시작:", { + url, + fileUri: file.uri, + fileName: file.fileName, + fileType: file.type, + fileSize: file.fileSize, + hasToken: !!token, + }); + + // FormData 생성 + const formData = new FormData(); + + // 파일 추가 + const fileData = { + uri: file.uri, + type: file.type || "image/jpeg", + name: file.fileName || "inbody.jpg", + } as any; + + formData.append("file", fileData); + + console.log("[INBODY][UPLOAD] FormData 생성 완료:", { + fileUri: fileData.uri, + fileType: fileData.type, + fileName: fileData.name, + }); + + console.log("[INBODY][UPLOAD] API 요청 전송 중..."); + const response = await axios.post(url, formData, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "multipart/form-data", + Accept: "application/json", + }, + }); + + console.log("[INBODY][UPLOAD] API 응답 받음:", { + status: response.status, + success: response.data?.success, + message: response.data?.message, + hasImageUrl: !!response.data?.imageUrl, + hasDraftData: !!response.data?.draftData, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error("[INBODY][UPLOAD] API 에러:", { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + url: error.config?.url, + }); + } else { + console.error("[INBODY][UPLOAD] 예상치 못한 에러:", error); + } + throw error; + } +}; + +/** + * 인바디 AI 코멘트 조회 + */ +export interface InBodyCommentRequest { + user_id: string; + period: string; // "2025-01-04 ~ 2025-07-04" + latest: { + date: string; // "2025-07-04" + weight: number; + body_fat_percentage: number; + skeletal_muscle_mass: number; + }; + trend: { + weight: "UP" | "DOWN" | "SAME"; + body_fat_percentage: "UP" | "DOWN" | "SAME"; + skeletal_muscle_mass: "UP" | "DOWN" | "SAME"; + }; +} + +export interface InBodyCommentResponse { + comment: string; +} + +export const getInBodyComment = async ( + request: InBodyCommentRequest +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + // GET 요청을 위한 쿼리 파라미터 구성 + const params = new URLSearchParams({ + user_id: request.user_id, + period: request.period, + date: request.latest.date, + weight: request.latest.weight.toString(), + body_fat_percentage: request.latest.body_fat_percentage.toString(), + skeletal_muscle_mass: request.latest.skeletal_muscle_mass.toString(), + weight_trend: request.trend.weight, + body_fat_percentage_trend: request.trend.body_fat_percentage, + skeletal_muscle_mass_trend: request.trend.skeletal_muscle_mass, + }); + + const url = `${INBODY_API_URL}/comment/daily/weight?${params.toString()}`; + + console.log("[INBODY][COMMENT] AI 코멘트 조회 요청:", { + url, + request, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + // 🔍 응답 전체 데이터 로깅 추가 + console.log("[INBODY][COMMENT] AI 코멘트 조회 응답 전체:", JSON.stringify(response.data, null, 2)); + console.log("[INBODY][COMMENT] response.data 타입:", typeof response.data); + console.log("[INBODY][COMMENT] response.data 키들:", response.data && typeof response.data === 'object' ? Object.keys(response.data) : 'N/A'); + console.log("[INBODY][COMMENT] response.data.comment:", response.data?.comment); + console.log("[INBODY][COMMENT] response.data.comments:", response.data?.comments); + console.log("[INBODY][COMMENT] response.data.message:", response.data?.message); + + // 🔧 comments (복수형) 필드도 확인 + let comment = response.data?.comments || response.data?.comment || response.data?.message || ""; + + // comments가 배열인 경우 랜덤으로 하나 선택 + if (Array.isArray(comment) && comment.length > 0) { + const randomIndex = Math.floor(Math.random() * comment.length); + const selectedComment = comment[randomIndex]; + console.log("[INBODY][COMMENT] 배열에서 랜덤 선택:", { + totalComments: comment.length, + selectedIndex: randomIndex, + selectedComment: selectedComment, + }); + comment = selectedComment; + } + + console.log("[INBODY][COMMENT] 추출된 comment:", comment); + console.log("[INBODY][COMMENT] AI 코멘트 조회 응답:", { + status: response.status, + comment: comment, + }); + + return { comment: typeof comment === 'string' ? comment : "" }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + console.error("[INBODY][COMMENT] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + data: errorData, + requestUrl: error.config?.url, + requestData: error.config?.data, + }); + + // 404나 400은 코멘트 없음으로 처리 + if (status === 404 || status === 400) { + console.log("[INBODY][COMMENT] 코멘트 없음 (404/400)"); + return { comment: "" }; + } + + // 500 에러는 서버 내부 오류이므로 빈 코멘트 반환 (사용자에게는 표시 안 함) + if (status === 500) { + console.warn( + "[INBODY][COMMENT] 서버 내부 오류 (500) - 코멘트 표시 안 함" + ); + return { comment: "" }; + } + } else { + console.error("[INBODY][COMMENT] 예상치 못한 에러:", error); + } + return { comment: "" }; + } +}; + +/** + * 인바디 히스토리 조회 + */ +export interface InBodyHistoryItem { + id: number; + measurementDate: string; + weight: number; + skeletalMuscleMass: number; + bodyFatMass: number; + bodyFatPercentage: number; + bmi: number; + score: number; +} + +export const getInBodyHistory = async (): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/history`; + + console.log("[INBODY][HISTORY] API 요청:", { + url, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + console.log("[INBODY][HISTORY] API 응답:", { + status: response.status, + dataCount: Array.isArray(response.data) ? response.data.length : 0, + }); + + return Array.isArray(response.data) ? response.data : []; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + console.error("[INBODY][HISTORY] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + data: errorData, + }); + + // 404나 400은 빈 배열 반환 + if (status === 404 || status === 400) { + console.log("[INBODY][HISTORY] 데이터 없음 (404/400)"); + return []; + } + } else { + console.error("[INBODY][HISTORY] 예상치 못한 에러:", error); + } + return []; + } +};