From 74bf1e700ca721498c9509e5b547aa06529d11d7 Mon Sep 17 00:00:00 2001 From: donggyu Date: Sun, 14 Dec 2025 07:41:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=94=BD=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/DislikedFoodsModal.tsx | 488 ++ src/components/modals/MealRecommendModal.tsx | 434 -- src/components/modals/PaymentMethodModal.tsx | 882 +-- .../modals/RoutineRecommendModal.tsx | 509 -- src/navigation/AppNavigator.tsx | 520 +- src/navigation/types.ts | 86 +- src/screens/auth/KakaoOnboardingScreen.tsx | 1812 ++--- src/screens/auth/LoginScreen.tsx | 1302 ++-- .../diet/MealRecommendHistoryScreen.tsx | 2214 ++++--- src/screens/diet/MealRecommendScreen.tsx | 5836 ++++++++--------- src/screens/diet/TempMealRecommendScreen.tsx | 1220 +++- .../exercise/RoutineRecommendScreen.tsx | 1273 ++-- .../exercise/TempRoutineRecommendScreen.tsx | 3615 +++++----- src/screens/main/MyPageScreen.tsx | 1451 ++-- src/services/authAPI.ts | 1744 ++--- src/services/myPlanAPI.ts | 122 +- src/services/recommendedExerciseAPI.ts | 429 +- src/services/recommendedMealAPI.ts | 922 +-- src/services/userPreferencesAPI.ts | 207 +- 19 files changed, 12712 insertions(+), 12354 deletions(-) create mode 100644 src/components/modals/DislikedFoodsModal.tsx delete mode 100644 src/components/modals/MealRecommendModal.tsx delete mode 100644 src/components/modals/RoutineRecommendModal.tsx diff --git a/src/components/modals/DislikedFoodsModal.tsx b/src/components/modals/DislikedFoodsModal.tsx new file mode 100644 index 0000000..127f8df --- /dev/null +++ b/src/components/modals/DislikedFoodsModal.tsx @@ -0,0 +1,488 @@ +// src/components/DislikedFoodsModal.tsx +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Modal, + Alert, + ActivityIndicator, + Dimensions, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { userPreferencesAPI } from "../../services/userPreferencesAPI"; + +const { width } = Dimensions.get("window"); + +interface DislikedFoodsModalProps { + visible: boolean; + onClose: () => void; + onSave?: (foods: string[]) => void; +} + +const DislikedFoodsModal = ({ + visible, + onClose, + onSave, +}: DislikedFoodsModalProps) => { + const [dislikedFoods, setDislikedFoods] = useState([]); + const [originalFoods, setOriginalFoods] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [loading, setLoading] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + // 모달이 열릴 때 데이터 로드 + useEffect(() => { + if (visible) { + loadDislikedFoods(); + } + }, [visible]); + + // 변경사항 감지 + useEffect(() => { + const changed = + JSON.stringify(dislikedFoods.sort()) !== + JSON.stringify(originalFoods.sort()); + setHasChanges(changed); + }, [dislikedFoods, originalFoods]); + + const loadDislikedFoods = async () => { + try { + setLoading(true); + const foods = await userPreferencesAPI.getDislikedFoods(); + setDislikedFoods(foods); + setOriginalFoods(foods); + setHasChanges(false); + } catch (error: any) { + console.error("Failed to load disliked foods", error); + Alert.alert("오류", "금지 식단을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleAdd = () => { + const trimmed = inputValue.trim(); + + if (!trimmed) { + Alert.alert("알림", "음식 이름을 입력해주세요."); + return; + } + + if (dislikedFoods.includes(trimmed)) { + Alert.alert("알림", "이미 추가된 음식입니다."); + return; + } + + setDislikedFoods((prev) => [...prev, trimmed]); + setInputValue(""); + }; + + const handleRemove = (food: string) => { + Alert.alert("삭제", `"${food}"를 삭제하시겠습니까?`, [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: () => { + setDislikedFoods((prev) => prev.filter((f) => f !== food)); + }, + }, + ]); + }; + + const handleSave = async () => { + try { + setLoading(true); + + await userPreferencesAPI.saveDislikedFoods(dislikedFoods); + + Alert.alert("성공", "금지 식단이 저장되었습니다."); + setOriginalFoods(dislikedFoods); + setHasChanges(false); + + if (onSave) { + onSave(dislikedFoods); + } + + onClose(); + } catch (error: any) { + console.error("Failed to save disliked foods", error); + Alert.alert("오류", error.message || "저장에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + if (hasChanges) { + Alert.alert( + "변경사항", + "저장하지 않은 변경사항이 있습니다. 나가시겠습니까?", + [ + { text: "취소", style: "cancel" }, + { + text: "나가기", + style: "destructive", + onPress: () => { + setDislikedFoods(originalFoods); + setInputValue(""); + setHasChanges(false); + onClose(); + }, + }, + ] + ); + } else { + onClose(); + } + }; + + return ( + + + + + + {/* 헤더 */} + + + + + 금지 식단 설정 + + + + {/* 안내 메시지 */} + + + + 알레르기나 선호하지 않는 음식을 추가하면 식단 추천에서 제외됩니다. + + + + + {/* 입력 필드 */} + + 음식 추가 + + + + + + + + + + + + + + {/* 금지 식단 목록 */} + + + 금지 식단 목록 ({dislikedFoods.length}) + + + {dislikedFoods.length === 0 ? ( + + + + 아직 추가된 금지 식단이 없습니다. + + + 위에서 음식을 추가해보세요. + + + ) : ( + + {dislikedFoods.map((food, index) => ( + + + + + + + {food} + + handleRemove(food)} + style={styles.removeBtn} + > + + + + + ))} + + )} + + + + {/* 저장 버튼 */} + + + + {loading ? ( + + ) : ( + <> + + + {hasChanges ? "저장하기" : "저장됨"} + + + )} + + + + + + + ); +}; + +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: "center", + backgroundColor: "#1e3a5f", + padding: 16, + marginHorizontal: 20, + marginTop: 20, + borderRadius: 12, + gap: 12, + }, + infoText: { + flex: 1, + fontSize: 14, + color: "#ffffff", + lineHeight: 20, + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + inputSection: { + marginBottom: 32, + }, + 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", + }, + listSection: { + marginBottom: 100, + }, + 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", + }, + 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(239,68,68,0.1)", + alignItems: "center", + justifyContent: "center", + }, + foodName: { + fontSize: 15, + fontWeight: "500", + color: "#ffffff", + flex: 1, + }, + removeBtn: { + padding: 4, + }, + footer: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + padding: 20, + backgroundColor: "#1a1a1a", + borderTopWidth: 1, + borderTopColor: "#333333", + }, + saveBtn: { + borderRadius: 12, + overflow: "hidden", + }, + saveBtnDisabled: { + opacity: 0.5, + }, + saveBtnGradient: { + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + paddingVertical: 16, + gap: 8, + }, + saveBtnText: { + fontSize: 16, + fontWeight: "700", + color: "#111111", + }, +}); + +export default DislikedFoodsModal; diff --git a/src/components/modals/MealRecommendModal.tsx b/src/components/modals/MealRecommendModal.tsx deleted file mode 100644 index 2d8b6ee..0000000 --- a/src/components/modals/MealRecommendModal.tsx +++ /dev/null @@ -1,434 +0,0 @@ -// src/components/MealRecommendModal.tsx -import React, { useState } from "react"; -import { - View, - Text, - Modal, - TouchableOpacity, - StyleSheet, - ScrollView, - Alert, - ActivityIndicator, -} from "react-native"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import { colors } from "../../theme/colors"; -import { authAPI } from "../../services"; - -interface MealRecommendModalProps { - isOpen: boolean; - onClose: () => void; -} - -const MealRecommendModal: React.FC = ({ - isOpen, - onClose, -}) => { - const [savedMeals, setSavedMeals] = useState([]); - const [selectedMeal, setSelectedMeal] = useState(null); - const [loading, setLoading] = useState(false); - - React.useEffect(() => { - if (isOpen) { - loadMeals(); - } - }, [isOpen]); - - // ✅ API에서 저장된 식단 불러오기 - const loadMeals = async () => { - try { - setLoading(true); - const meals = await authAPI.getSavedMealPlans(); - setSavedMeals(meals); - } catch (error) { - console.error("저장된 식단 불러오기 실패:", error); - Alert.alert("오류", "저장된 식단을 불러오는데 실패했습니다."); - } finally { - setLoading(false); - } - }; - - // ✅ API로 식단 삭제 - const handleDelete = async (mealId: number) => { - Alert.alert("삭제", "이 식단을 삭제하시겠습니까?", [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - const response = await authAPI.deleteMealPlan(mealId); - - if (response.success) { - await loadMeals(); // 목록 새로고침 - if (selectedMeal && selectedMeal.id === mealId) { - setSelectedMeal(null); - } - Alert.alert("성공", response.message || "식단이 삭제되었습니다."); - } else { - Alert.alert( - "오류", - response.message || "식단 삭제에 실패했습니다." - ); - } - } catch (error: any) { - console.error("식단 삭제 실패:", error); - Alert.alert("오류", error.message || "식단 삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ]); - }; - - // ✅ 식단 상세 정보 렌더링 - const renderMealDetail = (meal: any) => { - return ( - - setSelectedMeal(null)} - > - - 목록으로 - - - - {meal.planName || "식단 계획"} - handleDelete(meal.id)} - style={styles.deleteBtn} - > - - - - - {meal.description && ( - {meal.description} - )} - - {meal.recommendationReason && ( - - 💡 추천 이유 - - {meal.recommendationReason} - - - )} - - - - 총 칼로리 - - {meal.totalCalories || 0} kcal - - - - 탄수화물 - {meal.totalCarbs || 0}g - - - 단백질 - {meal.totalProtein || 0}g - - - 지방 - {meal.totalFat || 0}g - - - - {meal.meals && meal.meals.length > 0 && ( - - 식단 구성 - {meal.meals.map((mealItem: any, index: number) => ( - - - - {mealItem.mealTypeName || mealItem.mealType} - - - {mealItem.totalCalories || 0} kcal - - - - - 탄 {mealItem.totalCarbs || 0}g - - - 단 {mealItem.totalProtein || 0}g - - - 지 {mealItem.totalFat || 0}g - - - {mealItem.foods && mealItem.foods.length > 0 && ( - - {mealItem.foods.map((food: any, foodIndex: number) => ( - - • {food.foodName} - - {food.calories}kcal - - - ))} - - )} - - ))} - - )} - - {meal.createdAt && ( - - 생성일: {new Date(meal.createdAt).toLocaleDateString("ko-KR")} - - )} - - ); - }; - - return ( - - - - - - {selectedMeal ? "식단 상세보기" : "식단 추천 내역"} - - - - - - - - {loading ? ( - - - 불러오는 중... - - ) : !selectedMeal ? ( - savedMeals.length === 0 ? ( - - - 저장된 식단이 없습니다. - - 식단 추천을 받고 저장해보세요! - - - ) : ( - - {savedMeals.map((meal) => ( - setSelectedMeal(meal)} - > - - - 🍽️ {meal.planName || "식단 계획"} - - { - e.stopPropagation(); - handleDelete(meal.id); - }} - style={styles.deleteBtn} - > - - - - - - {meal.description && ( - - {meal.description} - - )} - - - - {meal.totalCalories || 0} kcal - - - - - 탄 {meal.totalCarbs || 0}g - - - - - 단 {meal.totalProtein || 0}g - - - - - 지 {meal.totalFat || 0}g - - - - {meal.createdAt && ( - - {new Date(meal.createdAt).toLocaleDateString( - "ko-KR" - )} - - )} - - 자세히 보기 → - - ))} - - ) - ) : ( - renderMealDetail(selectedMeal) - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.7)", - justifyContent: "flex-end", - }, - modalContent: { - backgroundColor: colors.cardBackground, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - maxHeight: "90%", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 20, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - title: { - fontSize: 20, - fontWeight: "700", - color: colors.text, - }, - closeBtn: { padding: 4 }, - body: { padding: 20 }, - emptyState: { alignItems: "center", padding: 40, gap: 12 }, - emptyText: { fontSize: 16, color: colors.text, textAlign: "center" }, - emptySubtitle: { fontSize: 14, color: colors.textLight, textAlign: "center" }, - list: { gap: 12 }, - card: { - backgroundColor: colors.grayLight, - padding: 16, - borderRadius: 12, - gap: 12, - }, - cardHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - date: { fontSize: 16, fontWeight: "600", color: colors.text, flex: 1 }, - deleteBtn: { padding: 4 }, - cardBody: { gap: 8 }, - description: { fontSize: 14, color: colors.textLight, lineHeight: 20 }, - summary: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - badge: { - backgroundColor: colors.primary + "20", - paddingHorizontal: 12, - paddingVertical: 4, - borderRadius: 12, - }, - badgeText: { fontSize: 12, color: colors.primary, fontWeight: "500" }, - cardDate: { fontSize: 12, color: colors.textLight, marginTop: 4 }, - viewDetail: { fontSize: 14, color: colors.primary, fontWeight: "500" }, - detail: { gap: 16 }, - backBtn: { - flexDirection: "row", - alignItems: "center", - gap: 8, - padding: 8, - marginBottom: 8, - }, - backBtnText: { fontSize: 16, color: colors.text }, - detailHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - detailTitle: { fontSize: 20, fontWeight: "700", color: colors.text, flex: 1 }, - detailDescription: { fontSize: 14, color: colors.textLight, lineHeight: 20 }, - recommendBox: { - backgroundColor: colors.primary + "15", - padding: 16, - borderRadius: 12, - borderLeftWidth: 4, - borderLeftColor: colors.primary, - }, - recommendLabel: { - fontSize: 14, - fontWeight: "600", - color: colors.text, - marginBottom: 8, - }, - recommendText: { fontSize: 14, color: colors.text, lineHeight: 20 }, - nutritionSummary: { - flexDirection: "row", - justifyContent: "space-between", - backgroundColor: colors.grayLight, - padding: 16, - borderRadius: 12, - gap: 8, - }, - nutritionItem: { flex: 1, alignItems: "center" }, - nutritionLabel: { fontSize: 12, color: colors.textLight, marginBottom: 4 }, - nutritionValue: { fontSize: 16, fontWeight: "700", color: colors.primary }, - mealsSection: { gap: 12 }, - sectionTitle: { - fontSize: 16, - fontWeight: "600", - color: colors.text, - marginBottom: 4, - }, - mealItem: { - backgroundColor: colors.grayLight, - padding: 16, - borderRadius: 12, - gap: 8, - }, - mealItemHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - mealTypeName: { fontSize: 16, fontWeight: "600", color: colors.text }, - mealCalories: { fontSize: 14, fontWeight: "600", color: colors.primary }, - mealNutrients: { flexDirection: "row", gap: 12 }, - nutrientText: { fontSize: 12, color: colors.textLight }, - foodsList: { marginTop: 8, gap: 6 }, - foodItem: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 4, - }, - foodName: { fontSize: 14, color: colors.text, flex: 1 }, - foodCalories: { fontSize: 12, color: colors.textLight }, - createdAt: { - fontSize: 12, - color: colors.textLight, - textAlign: "center", - marginTop: 8, - }, -}); - -export default MealRecommendModal; diff --git a/src/components/modals/PaymentMethodModal.tsx b/src/components/modals/PaymentMethodModal.tsx index c68a7fd..43ccf7a 100644 --- a/src/components/modals/PaymentMethodModal.tsx +++ b/src/components/modals/PaymentMethodModal.tsx @@ -1,441 +1,441 @@ -// 내 플랜 보기로 재활용 - -import React, { useState, useEffect } from "react"; -import { - View, - Text, - Modal, - TouchableOpacity, - StyleSheet, - ScrollView, - Alert, - ActivityIndicator, -} from "react-native"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import { myPlanAPI, SubscriptionResponse } from "../../services/myPlanAPI"; - -// ✅ 1. Props 인터페이스 수정: onCancelSuccess 추가 -interface PaymentMethodModalProps { - isOpen: boolean; - onClose: () => void; - onCancelSuccess?: () => void; // 이 부분이 있어야 MyPageScreen에서 함수를 넘겨줄 수 있습니다. -} - -const PaymentMethodModal: React.FC = ({ - isOpen, - onClose, - onCancelSuccess, // ✅ 2. Props로 받기 -}) => { - const [subscription, setSubscription] = useState( - null - ); - const [isLoading, setIsLoading] = useState(false); - - // 모달이 열릴 때 데이터 조회 - useEffect(() => { - if (isOpen) { - fetchSubscriptionData(); - } - }, [isOpen]); - - const fetchSubscriptionData = async () => { - try { - setIsLoading(true); - const data = await myPlanAPI.getMySubscription(); - setSubscription(data); - } catch (error) { - console.error(error); - } finally { - setIsLoading(false); - } - }; - - // ✅ 구독 취소 핸들러 - const handleCancelSubscription = () => { - Alert.alert( - "구독 취소", - "정말로 구독을 취소하시겠습니까?\n취소 시 보안을 위해 로그아웃됩니다.", - [ - { text: "유지하기", style: "cancel" }, - { - text: "취소하기", - style: "destructive", - onPress: async () => { - try { - setIsLoading(true); - - // 1. 취소 API 호출 - const response = await myPlanAPI.cancelSubscription(); - - if (response.success) { - // 2. 성공 알림 및 로그아웃 실행 - Alert.alert( - "알림", - "구독이 정상적으로 취소되었습니다.\n로그아웃 됩니다.", - [ - { - text: "확인", - onPress: () => { - onClose(); // 모달 닫기 - // ✅ 3. 부모(MyPageScreen)에서 받은 로그아웃 함수 실행 - if (onCancelSuccess) { - onCancelSuccess(); - } - }, - }, - ] - ); - } - } catch (error: any) { - Alert.alert( - "오류", - error.message || "취소 요청 중 문제가 발생했습니다." - ); - } finally { - setIsLoading(false); - } - }, - }, - ] - ); - }; - - // 날짜 포맷팅 (YYYY.MM.DD) - const formatDate = (dateString: string | null) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart( - 2, - "0" - )}.${String(date.getDate()).padStart(2, "0")}`; - }; - - // 플랜 코드 한글 변환 - const getPlanName = (code: string | null) => { - switch (code) { - case "PREMIUM_MONTHLY": - return "프리미엄 월간 멤버십"; - default: - return "프리미엄 연간 멤버십"; - } - }; - - // 결제 수단 한글 변환 - const getProviderName = (provider: string | null) => { - switch (provider) { - case "KAKAOPAY": - return "카카오페이"; - case "NAVERPAY": - return "네이버페이"; - - default: - return provider || "-"; - } - }; - - // 현재 활성 구독 여부 확인 - const hasActive = subscription?.hasActiveSubscription; - - return ( - - - - {/* 헤더 */} - - 내 구독 정보 - - - - - - {/* 로딩 상태 표시 */} - {isLoading ? ( - - - 처리 중... - - ) : ( - - {/* ✅ 활성 구독이 있을 때만 정보 표시 */} - {subscription && hasActive ? ( - <> - {/* 1. 플랜 정보 카드 */} - - 이용 중인 상품 - - - - - - - {getPlanName(subscription.planCode)} - - - - {subscription.status === "active" - ? "이용중" - : subscription.status} - - - - - - - - {/* 상세 정보 */} - - - 시작일 - - {formatDate(subscription.startedAt)} - - - - 다음 결제일 - - {formatDate(subscription.expiredAt)} - - - - 결제 수단 - - - - {getProviderName(subscription.provider)} - - - - - - - - {/* 2. 취소 버튼 */} - - - 구독 취소 - - - - ) : ( - /* ✅ 구독 정보가 없거나 취소된 경우 */ - - {subscription?.status === "canceled" ? ( - <> - - - 구독이 취소되었습니다. - - - ) : ( - <> - - - 사용 중인 구독 상품이 없습니다. - - - )} - - )} - - {/* 3. 하단 안내 문구 */} - - 💡 멤버십 안내 - - - • 결제는 매월/매년 수동으로 진행 해야 합니다. - - - - - )} - - - - ); -}; - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.7)", - justifyContent: "center", - alignItems: "center", - padding: 20, - }, - modalContent: { - backgroundColor: "#2a2a2a", - borderRadius: 16, - width: "100%", - maxWidth: 400, - maxHeight: "80%", - overflow: "hidden", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - paddingHorizontal: 20, - borderBottomWidth: 1, - borderBottomColor: "#404040", - }, - title: { - fontSize: 18, - fontWeight: "700", - color: "#ffffff", - }, - closeBtn: { - padding: 4, - }, - loadingContainer: { - padding: 40, - alignItems: "center", - justifyContent: "center", - gap: 12, - }, - loadingText: { - color: "#cccccc", - fontSize: 14, - }, - body: { - padding: 20, - }, - section: { - marginBottom: 24, - }, - sectionTitle: { - fontSize: 15, - fontWeight: "600", - color: "#cccccc", - marginBottom: 10, - marginLeft: 4, - }, - planCard: { - backgroundColor: "rgba(255, 255, 255, 0.05)", - borderWidth: 1, - borderColor: "#4ade80", - borderRadius: 12, - padding: 20, - }, - planHeader: { - flexDirection: "row", - alignItems: "center", - gap: 14, - marginBottom: 16, - }, - planTitleContainer: { - flex: 1, - }, - planName: { - fontSize: 18, - fontWeight: "700", - color: "#ffffff", - marginBottom: 4, - }, - statusBadge: { - backgroundColor: "#4ade80", - alignSelf: "flex-start", - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 6, - }, - statusText: { - color: "#000000", - fontSize: 11, - fontWeight: "700", - }, - divider: { - height: 1, - backgroundColor: "#404040", - marginBottom: 16, - }, - infoGrid: { - gap: 12, - }, - infoRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - infoLabel: { - fontSize: 14, - color: "#999999", - }, - infoValue: { - fontSize: 14, - color: "#ffffff", - fontWeight: "500", - }, - providerContainer: { - flexDirection: "row", - alignItems: "center", - gap: 6, - }, - cancelBtn: { - width: "100%", - padding: 14, - backgroundColor: "rgba(239, 68, 68, 0.15)", - borderWidth: 1, - borderColor: "#ef4444", - borderRadius: 10, - alignItems: "center", - }, - cancelBtnText: { - color: "#ef4444", - fontSize: 15, - fontWeight: "600", - }, - emptyContainer: { - alignItems: "center", - justifyContent: "center", - paddingVertical: 40, - gap: 16, - }, - emptyText: { - color: "#999999", - fontSize: 15, - marginTop: 10, - }, - infoBox: { - backgroundColor: "rgba(74, 222, 128, 0.05)", - borderRadius: 10, - padding: 16, - marginTop: 0, - }, - infoBoxTitle: { - color: "#4ade80", - fontSize: 14, - fontWeight: "600", - marginBottom: 10, - }, - infoList: { - gap: 6, - }, - infoItem: { - color: "#cccccc", - fontSize: 12, - lineHeight: 18, - }, -}); - -export default PaymentMethodModal; +// 내 플랜 보기로 재활용 + +import React, { useState, useEffect } from "react"; +import { + View, + Text, + Modal, + TouchableOpacity, + StyleSheet, + ScrollView, + Alert, + ActivityIndicator, +} from "react-native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { myPlanAPI, SubscriptionResponse } from "../../services/myPlanAPI"; + +// ✅ 1. Props 인터페이스 수정: onCancelSuccess 추가 +interface PaymentMethodModalProps { + isOpen: boolean; + onClose: () => void; + onCancelSuccess?: () => void; // 이 부분이 있어야 MyPageScreen에서 함수를 넘겨줄 수 있습니다. +} + +const PaymentMethodModal: React.FC = ({ + isOpen, + onClose, + onCancelSuccess, // ✅ 2. Props로 받기 +}) => { + const [subscription, setSubscription] = useState( + null + ); + const [isLoading, setIsLoading] = useState(false); + + // 모달이 열릴 때 데이터 조회 + useEffect(() => { + if (isOpen) { + fetchSubscriptionData(); + } + }, [isOpen]); + + const fetchSubscriptionData = async () => { + try { + setIsLoading(true); + const data = await myPlanAPI.getMySubscription(); + setSubscription(data); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + + // ✅ 구독 취소 핸들러 + const handleCancelSubscription = () => { + Alert.alert( + "구독 취소", + "정말로 구독을 취소하시겠습니까?\n취소 시 보안을 위해 로그아웃됩니다.", + [ + { text: "유지하기", style: "cancel" }, + { + text: "취소하기", + style: "destructive", + onPress: async () => { + try { + setIsLoading(true); + + // 1. 취소 API 호출 + const response = await myPlanAPI.cancelSubscription(); + + if (response.success) { + // 2. 성공 알림 및 로그아웃 실행 + Alert.alert( + "알림", + "구독이 정상적으로 취소되었습니다.\n로그아웃 됩니다.", + [ + { + text: "확인", + onPress: () => { + onClose(); // 모달 닫기 + // ✅ 3. 부모(MyPageScreen)에서 받은 로그아웃 함수 실행 + if (onCancelSuccess) { + onCancelSuccess(); + } + }, + }, + ] + ); + } + } catch (error: any) { + Alert.alert( + "오류", + error.message || "취소 요청 중 문제가 발생했습니다." + ); + } finally { + setIsLoading(false); + } + }, + }, + ] + ); + }; + + // 날짜 포맷팅 (YYYY.MM.DD) + const formatDate = (dateString: string | null) => { + if (!dateString) return "-"; + const date = new Date(dateString); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart( + 2, + "0" + )}.${String(date.getDate()).padStart(2, "0")}`; + }; + + // 플랜 코드 한글 변환 + const getPlanName = (code: string | null) => { + switch (code) { + case "PREMIUM_MONTHLY": + return "프리미엄 월간 멤버십"; + default: + return "프리미엄 연간 멤버십"; + } + }; + + // 결제 수단 한글 변환 + const getProviderName = (provider: string | null) => { + switch (provider) { + case "KAKAOPAY": + return "카카오페이"; + case "NAVERPAY": + return "네이버페이"; + + default: + return provider || "-"; + } + }; + + // 현재 활성 구독 여부 확인 + const hasActive = subscription?.hasActiveSubscription; + + return ( + + + + {/* 헤더 */} + + 내 구독 정보 + + + + + + {/* 로딩 상태 표시 */} + {isLoading ? ( + + + 처리 중... + + ) : ( + + {/* ✅ 활성 구독이 있을 때만 정보 표시 */} + {subscription && hasActive ? ( + <> + {/* 1. 플랜 정보 카드 */} + + 이용 중인 상품 + + + + + + + {getPlanName(subscription.planCode)} + + + + {subscription.status === "active" + ? "이용중" + : subscription.status} + + + + + + + + {/* 상세 정보 */} + + + 시작일 + + {formatDate(subscription.startedAt)} + + + + 다음 결제일 + + {formatDate(subscription.expiredAt)} + + + + 결제 수단 + + + + {getProviderName(subscription.provider)} + + + + + + + + {/* 2. 취소 버튼 */} + + + 구독 취소 + + + + ) : ( + /* ✅ 구독 정보가 없거나 취소된 경우 */ + + {subscription?.status === "canceled" ? ( + <> + + + 구독이 취소되었습니다. + + + ) : ( + <> + + + 사용 중인 구독 상품이 없습니다. + + + )} + + )} + + {/* 3. 하단 안내 문구 */} + + 💡 멤버십 안내 + + + • 결제는 매월/매년 수동으로 진행 해야 합니다. + + + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + modalContent: { + backgroundColor: "#2a2a2a", + borderRadius: 16, + width: "100%", + maxWidth: 400, + maxHeight: "80%", + overflow: "hidden", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 16, + paddingHorizontal: 20, + borderBottomWidth: 1, + borderBottomColor: "#404040", + }, + title: { + fontSize: 18, + fontWeight: "700", + color: "#ffffff", + }, + closeBtn: { + padding: 4, + }, + loadingContainer: { + padding: 40, + alignItems: "center", + justifyContent: "center", + gap: 12, + }, + loadingText: { + color: "#cccccc", + fontSize: 14, + }, + body: { + padding: 20, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 15, + fontWeight: "600", + color: "#cccccc", + marginBottom: 10, + marginLeft: 4, + }, + planCard: { + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "#4ade80", + borderRadius: 12, + padding: 20, + }, + planHeader: { + flexDirection: "row", + alignItems: "center", + gap: 14, + marginBottom: 16, + }, + planTitleContainer: { + flex: 1, + }, + planName: { + fontSize: 18, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + }, + statusBadge: { + backgroundColor: "#4ade80", + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + }, + statusText: { + color: "#000000", + fontSize: 11, + fontWeight: "700", + }, + divider: { + height: 1, + backgroundColor: "#404040", + marginBottom: 16, + }, + infoGrid: { + gap: 12, + }, + infoRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + infoLabel: { + fontSize: 14, + color: "#999999", + }, + infoValue: { + fontSize: 14, + color: "#ffffff", + fontWeight: "500", + }, + providerContainer: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + cancelBtn: { + width: "100%", + padding: 14, + backgroundColor: "rgba(239, 68, 68, 0.15)", + borderWidth: 1, + borderColor: "#ef4444", + borderRadius: 10, + alignItems: "center", + }, + cancelBtnText: { + color: "#ef4444", + fontSize: 15, + fontWeight: "600", + }, + emptyContainer: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 40, + gap: 16, + }, + emptyText: { + color: "#999999", + fontSize: 15, + marginTop: 10, + }, + infoBox: { + backgroundColor: "rgba(74, 222, 128, 0.05)", + borderRadius: 10, + padding: 16, + marginTop: 0, + }, + infoBoxTitle: { + color: "#4ade80", + fontSize: 14, + fontWeight: "600", + marginBottom: 10, + }, + infoList: { + gap: 6, + }, + infoItem: { + color: "#cccccc", + fontSize: 12, + lineHeight: 18, + }, +}); + +export default PaymentMethodModal; diff --git a/src/components/modals/RoutineRecommendModal.tsx b/src/components/modals/RoutineRecommendModal.tsx deleted file mode 100644 index 8cdb969..0000000 --- a/src/components/modals/RoutineRecommendModal.tsx +++ /dev/null @@ -1,509 +0,0 @@ -import React, {useState} from 'react'; -import { - View, - Text, - Modal, - TouchableOpacity, - StyleSheet, - ScrollView, - Alert, - Dimensions, -} from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import {Ionicons as Icon} from '@expo/vector-icons'; - -interface RoutineRecommendModalProps { - isOpen: boolean; - onClose: () => void; -} - -const RoutineRecommendModal: React.FC = ({ - isOpen, - onClose, -}) => { - const [savedRoutines, setSavedRoutines] = useState([]); - const [selectedRoutine, setSelectedRoutine] = useState(null); - const [selectedDay, setSelectedDay] = useState(0); - - const weekDays = ['1일차', '2일차', '3일차', '4일차', '5일차', '6일차', '7일차']; - - React.useEffect(() => { - if (isOpen) { - loadRoutines(); - } - }, [isOpen]); - - const loadRoutines = async () => { - try { - const stored = await AsyncStorage.getItem('savedRoutines'); - if (stored) { - setSavedRoutines(JSON.parse(stored)); - } - } catch (error) { - console.log('Failed to load routines', error); - } - }; - - const handleRoutineClick = (routine: any) => { - setSelectedRoutine(routine); - setSelectedDay(0); - }; - - const handleBack = () => { - setSelectedRoutine(null); - setSelectedDay(0); - }; - - const handleDelete = async (routineId: number, event?: any) => { - if (event) { - event.stopPropagation?.(); - } - Alert.alert('삭제', '이 루틴을 삭제하시겠습니까?', [ - {text: '취소', style: 'cancel'}, - { - text: '삭제', - style: 'destructive', - onPress: async () => { - const updated = savedRoutines.filter(r => r.id !== routineId); - await AsyncStorage.setItem('savedRoutines', JSON.stringify(updated)); - setSavedRoutines(updated); - if (selectedRoutine && selectedRoutine.id === routineId) { - setSelectedRoutine(null); - setSelectedDay(0); - } - }, - }, - ]); - }; - - if (!isOpen) return null; - - return ( - - - true}> - - - {selectedRoutine ? '루틴 상세보기' : '운동 추천 내역'} - - - - - - - - {!selectedRoutine ? ( - savedRoutines.length === 0 ? ( - - 저장된 운동 루틴이 없습니다. - - 운동 추천을 받고 루틴을 저장해보세요! - - - 추천받으러 가기 → - - - ) : ( - - {savedRoutines.map(routine => ( - handleRoutineClick(routine)} - activeOpacity={0.98}> - - - 📅 - {routine.date} - - handleDelete(routine.id, e)} - style={styles.deleteBtn} - activeOpacity={0.9}> - 🗑️ - - - - {routine.level && ( - - {routine.level} - - )} - {routine.targetParts && routine.targetParts.length > 0 && ( - - - 집중: {routine.targetParts.join(', ')} - - - )} - {routine.weakParts && routine.weakParts.length > 0 && ( - - - 주의: {routine.weakParts.join(', ')} - - - )} - - - 자세히 보기 → - - - ))} - - ) - ) : ( - - - ← 목록으로 - - - - {selectedRoutine.date} - - {selectedRoutine.level && ( - - - {selectedRoutine.level} - - - )} - {selectedRoutine.targetParts && - selectedRoutine.targetParts.length > 0 && ( - - - 집중: {selectedRoutine.targetParts.join(', ')} - - - )} - - - - - {weekDays.map((day, index) => ( - setSelectedDay(index)} - activeOpacity={0.8}> - - {day} - - - ))} - - - - {selectedRoutine.routine?.[selectedDay]?.map( - (exercise: any, index: number) => ( - - - - {exercise.icon || '💪'} - - - - {exercise.name} - {exercise.detail} - - - ), - )} - - - )} - - - - - ); -}; - -const screenWidth = Dimensions.get('window').width; -const maxWidth = Math.min(screenWidth * 0.9, 390); - -const styles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - justifyContent: 'center', - alignItems: 'center', - padding: 20, - }, - modalContent: { - backgroundColor: '#1a1a1a', - borderRadius: 20, - width: maxWidth, - maxWidth: '90%', - maxHeight: '80%', - overflow: 'hidden', - zIndex: 1000, - elevation: 5, - shadowColor: '#000', - shadowOffset: {width: 0, height: 2}, - shadowOpacity: 0.25, - shadowRadius: 3.84, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 20, - borderBottomWidth: 1, - borderBottomColor: '#333333', - backgroundColor: '#111111', - }, - title: { - fontSize: 20, - fontWeight: '700', - color: '#ffffff', - }, - closeBtn: { - width: 32, - height: 32, - justifyContent: 'center', - alignItems: 'center', - }, - closeBtnText: { - fontSize: 28, - color: '#999999', - }, - body: { - flex: 1, - padding: 20, - }, - emptyState: { - alignItems: 'center', - paddingVertical: 60, - paddingHorizontal: 20, - }, - emptyText: { - fontSize: 16, - color: '#999999', - textAlign: 'center', - marginBottom: 10, - }, - emptySubtitle: { - fontSize: 14, - color: '#666666', - textAlign: 'center', - marginBottom: 24, - }, - goToRecommendBtn: { - backgroundColor: '#e3ff7c', - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 12, - marginTop: 16, - }, - goToRecommendBtnText: { - fontSize: 15, - fontWeight: '600', - color: '#111111', - }, - list: { - gap: 16, - }, - card: { - backgroundColor: '#222222', - borderRadius: 12, - padding: 16, - borderWidth: 2, - borderColor: 'transparent', - }, - cardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 12, - }, - dateContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - dateIcon: { - fontSize: 18, - }, - date: { - fontSize: 16, - fontWeight: '600', - color: '#ffffff', - }, - deleteBtn: { - padding: 4, - }, - deleteBtnText: { - fontSize: 18, - }, - cardBody: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 12, - }, - badge: { - paddingVertical: 4, - paddingHorizontal: 10, - borderRadius: 12, - }, - levelBadge: { - backgroundColor: '#e3ff7c', - }, - targetBadge: { - backgroundColor: '#4a90e2', - }, - weakBadge: { - backgroundColor: '#ff6b6b', - }, - badgeText: { - fontSize: 12, - fontWeight: '500', - color: '#111111', - }, - targetBadgeText: { - fontSize: 12, - fontWeight: '500', - color: '#ffffff', - }, - weakBadgeText: { - fontSize: 12, - fontWeight: '500', - color: '#ffffff', - }, - cardFooter: { - alignItems: 'flex-end', - }, - viewDetail: { - fontSize: 14, - color: '#e3ff7c', - fontWeight: '500', - }, - detail: { - gap: 20, - }, - backBtn: { - backgroundColor: '#2a2a2a', - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignSelf: 'flex-start', - }, - backBtnText: { - fontSize: 14, - fontWeight: '500', - color: '#ffffff', - }, - detailInfo: { - backgroundColor: '#222222', - padding: 16, - borderRadius: 12, - }, - detailDate: { - fontSize: 18, - fontWeight: '700', - color: '#ffffff', - marginBottom: 12, - }, - detailBadges: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - }, - detailBadge: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 12, - backgroundColor: '#e3ff7c', - }, - detailBadgeText: { - fontSize: 13, - fontWeight: '500', - color: '#111111', - }, - dayTabsContainer: { - marginVertical: 8, - }, - dayTabs: { - gap: 8, - paddingBottom: 8, - }, - dayTab: { - paddingVertical: 8, - paddingHorizontal: 16, - backgroundColor: '#222222', - borderRadius: 20, - }, - dayTabActive: { - backgroundColor: '#e3ff7c', - }, - dayTabText: { - fontSize: 14, - fontWeight: '500', - color: '#999999', - }, - dayTabTextActive: { - color: '#111111', - fontWeight: '600', - }, - exerciseList: { - gap: 12, - }, - exerciseItem: { - backgroundColor: '#2a2a2a', - borderRadius: 12, - padding: 16, - flexDirection: 'row', - alignItems: 'center', - gap: 15, - }, - exerciseIcon: { - width: 50, - height: 50, - borderRadius: 10, - backgroundColor: '#333333', - justifyContent: 'center', - alignItems: 'center', - }, - exerciseIconText: { - fontSize: 32, - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: '600', - color: '#ffffff', - marginBottom: 5, - }, - exerciseDetail: { - fontSize: 14, - color: '#aaaaaa', - }, -}); - -export default RoutineRecommendModal; - diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 75570ad..b6d3a95 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1,260 +1,260 @@ -import React from "react"; -import { NavigationContainer } from "@react-navigation/native"; -import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import * as Linking from "expo-linking"; -import { RootStackParamList, MainTabParamList } from "./types"; -import { ROUTES } from "../constants/routes"; -import { TAB_BAR_THEME, ICONS } from "../constants/theme"; -import { DateProvider } from "../contexts/DateContext"; - -// Auth Screens -import OnboardingScreen from "../screens/auth/OnboardingScreen"; -import LoginScreen from "../screens/auth/LoginScreen"; -import SignupScreen from "../screens/auth/SignupScreen"; -import FindIdScreen from "../screens/auth/FindIdScreen"; -import ResetPasswordScreen from "../screens/auth/ResetPasswordScreen"; -import KakaoOnboardingScreen from "../screens/auth/KakaoOnboardingScreen"; - -// Main Screens -import HomeScreen from "../screens/main/HomeScreen"; -import MyPageScreen from "../screens/main/MyPageScreen"; -import StatsScreen from "../screens/main/StatsScreen"; - -// Diet Screens -import DietScreen from "../screens/diet/DietScreen"; -import MealAddScreen from "../screens/diet/MealAddScreen"; -import FoodSearchScreen from "../screens/diet/FoodSearchScreen"; -import MealDetailScreen from "../screens/diet/MealDetailScreen"; -import MealRecommendScreen from "../screens/diet/MealRecommendScreen"; -import MealRecommendHistoryScreen from "../screens/diet/MealRecommendHistoryScreen"; -import FoodAddOptionsScreen from "../screens/diet/FoodAddOptionsScreen"; -import TempMealRecommendScreen from "../screens/diet/TempMealRecommendScreen"; - -// Exercise Screens -import ExerciseScreen from "../screens/exercise/ExerciseScreen"; -import ExerciseDetailScreen from "../screens/exercise/ExerciseDetailScreen"; -import RoutineRecommendScreen from "../screens/exercise/RoutineRecommendScreen"; -import RoutineRecommendNewScreen from "../screens/exercise/RoutineRecommendNewScreen"; -import TempRoutineRecommendScreen from "../screens/exercise/TempRoutineRecommendScreen"; - -// Analysis Screens -import AnalysisScreen from "../screens/analysis/AnalysisScreen"; -import CalendarScreen from "../screens/analysis/CalendarScreen"; -import GoalScreen from "../screens/analysis/GoalScreen"; -import HealthScoreTrendScreen from "../screens/analysis/HealthScoreTrendScreen"; - -// InBody Screens -import InBodyScreen from "../screens/inbody/InBodyScreen"; -import InBodyManualScreen from "../screens/inbody/InBodyManualScreen"; - -// Chatbot Screens -import ChatbotScreen from "../screens/chatbot/ChatbotScreen"; - -// PaymentSucess Screens -import PaymentSuccessScreen from "../screens/pay/PaymentSuccessScreen"; -import PaymentFailScreen from "../screens/pay/PaymentFailScreen"; -import PaymentCancelScreen from "../screens/pay/PaymentCancelScreen"; - -const Stack = createNativeStackNavigator(); -const Tab = createBottomTabNavigator(); - -//딥링크 설정 -// Expo Go에서는 exp:// 스킴 사용, 스탠드얼론 빌드에서는 intelfit:// 스킴 사용 -const linking = { - prefixes: ["intelfit://", "exp://"], - config: { - screens: { - PaymentSuccess: "pay/success", - PaymentFail: "pay/fail", - PaymentCancel: "pay/cancel", - Login: "auth/kakao", - }, - }, -}; - -function MainTabs() { - return ( - ({ - tabBarIcon: ({ focused, color, size }) => { - let iconName: any; - - switch (route.name) { - case "Home": - iconName = focused ? ICONS.HOME.active : ICONS.HOME.inactive; - break; - case "Stats": - iconName = focused ? ICONS.STATS.active : ICONS.STATS.inactive; - break; - case "Chatbot": - iconName = focused - ? ICONS.CHATBOT.active - : ICONS.CHATBOT.inactive; - break; - case "Analysis": - iconName = focused - ? ICONS.ANALYSIS.active - : ICONS.ANALYSIS.inactive; - break; - case "MyPage": - iconName = focused - ? ICONS.MY_PAGE.active - : ICONS.MY_PAGE.inactive; - break; - default: - iconName = ICONS.HOME.inactive; - } - - return ; - }, - tabBarActiveTintColor: TAB_BAR_THEME.activeTintColor, - tabBarInactiveTintColor: TAB_BAR_THEME.inactiveTintColor, - headerShown: false, - tabBarStyle: { - backgroundColor: TAB_BAR_THEME.backgroundColor, - borderTopColor: TAB_BAR_THEME.borderTopColor, - }, - })} - > - - - - - - - ); -} - -export default function AppNavigator() { - return ( - - - - {/* Auth Stack */} - - - - - - - {/* Main Tabs */} - - {/* Payment Stack*/} - - - - - {/* Diet Stack */} - - - - - - - - - {/* Exercise Stack */} - null, - }} - /> - - - - - {/* Analysis Stack */} - - - - - {/* InBody Stack */} - - - {/* Chatbot Stack */} - - - - - ); -} +import React from "react"; +import { NavigationContainer } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import * as Linking from "expo-linking"; +import { RootStackParamList, MainTabParamList } from "./types"; +import { ROUTES } from "../constants/routes"; +import { TAB_BAR_THEME, ICONS } from "../constants/theme"; +import { DateProvider } from "../contexts/DateContext"; + +// Auth Screens +import OnboardingScreen from "../screens/auth/OnboardingScreen"; +import LoginScreen from "../screens/auth/LoginScreen"; +import SignupScreen from "../screens/auth/SignupScreen"; +import FindIdScreen from "../screens/auth/FindIdScreen"; +import ResetPasswordScreen from "../screens/auth/ResetPasswordScreen"; +import KakaoOnboardingScreen from "../screens/auth/KakaoOnboardingScreen"; + +// Main Screens +import HomeScreen from "../screens/main/HomeScreen"; +import MyPageScreen from "../screens/main/MyPageScreen"; +import StatsScreen from "../screens/main/StatsScreen"; + +// Diet Screens +import DietScreen from "../screens/diet/DietScreen"; +import MealAddScreen from "../screens/diet/MealAddScreen"; +import FoodSearchScreen from "../screens/diet/FoodSearchScreen"; +import MealDetailScreen from "../screens/diet/MealDetailScreen"; +import MealRecommendScreen from "../screens/diet/MealRecommendScreen"; +import MealRecommendHistoryScreen from "../screens/diet/MealRecommendHistoryScreen"; +import FoodAddOptionsScreen from "../screens/diet/FoodAddOptionsScreen"; +import TempMealRecommendScreen from "../screens/diet/TempMealRecommendScreen"; + +// Exercise Screens +import ExerciseScreen from "../screens/exercise/ExerciseScreen"; +import ExerciseDetailScreen from "../screens/exercise/ExerciseDetailScreen"; +import RoutineRecommendScreen from "../screens/exercise/RoutineRecommendScreen"; +import RoutineRecommendNewScreen from "../screens/exercise/RoutineRecommendNewScreen"; +import TempRoutineRecommendScreen from "../screens/exercise/TempRoutineRecommendScreen"; + +// Analysis Screens +import AnalysisScreen from "../screens/analysis/AnalysisScreen"; +import CalendarScreen from "../screens/analysis/CalendarScreen"; +import GoalScreen from "../screens/analysis/GoalScreen"; +import HealthScoreTrendScreen from "../screens/analysis/HealthScoreTrendScreen"; + +// InBody Screens +import InBodyScreen from "../screens/inbody/InBodyScreen"; +import InBodyManualScreen from "../screens/inbody/InBodyManualScreen"; + +// Chatbot Screens +import ChatbotScreen from "../screens/chatbot/ChatbotScreen"; + +// PaymentSucess Screens +import PaymentSuccessScreen from "../screens/pay/PaymentSuccessScreen"; +import PaymentFailScreen from "../screens/pay/PaymentFailScreen"; +import PaymentCancelScreen from "../screens/pay/PaymentCancelScreen"; + +const Stack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +//딥링크 설정 +// Expo Go에서는 exp:// 스킴 사용, 스탠드얼론 빌드에서는 intelfit:// 스킴 사용 +const linking = { + prefixes: ["intelfit://", "exp://"], + config: { + screens: { + PaymentSuccess: "pay/success", + PaymentFail: "pay/fail", + PaymentCancel: "pay/cancel", + Login: "auth/kakao", + }, + }, +}; + +function MainTabs() { + return ( + ({ + tabBarIcon: ({ focused, color, size }) => { + let iconName: any; + + switch (route.name) { + case "Home": + iconName = focused ? ICONS.HOME.active : ICONS.HOME.inactive; + break; + case "Stats": + iconName = focused ? ICONS.STATS.active : ICONS.STATS.inactive; + break; + case "Chatbot": + iconName = focused + ? ICONS.CHATBOT.active + : ICONS.CHATBOT.inactive; + break; + case "Analysis": + iconName = focused + ? ICONS.ANALYSIS.active + : ICONS.ANALYSIS.inactive; + break; + case "MyPage": + iconName = focused + ? ICONS.MY_PAGE.active + : ICONS.MY_PAGE.inactive; + break; + default: + iconName = ICONS.HOME.inactive; + } + + return ; + }, + tabBarActiveTintColor: TAB_BAR_THEME.activeTintColor, + tabBarInactiveTintColor: TAB_BAR_THEME.inactiveTintColor, + headerShown: false, + tabBarStyle: { + backgroundColor: TAB_BAR_THEME.backgroundColor, + borderTopColor: TAB_BAR_THEME.borderTopColor, + }, + })} + > + + + + + + + ); +} + +export default function AppNavigator() { + return ( + + + + {/* Auth Stack */} + + + + + + + {/* Main Tabs */} + + {/* Payment Stack*/} + + + + + {/* Diet Stack */} + + + + + + + + + {/* Exercise Stack */} + null, + }} + /> + + + + + {/* Analysis Stack */} + + + + + {/* InBody Stack */} + + + {/* Chatbot Stack */} + + + + + ); +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 969c4b0..bd205f1 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,43 +1,43 @@ -export type RootStackParamList = { - Splash: undefined; - Onboarding: undefined; - Login: undefined; - Signup: undefined; - FindId: undefined; - ResetPassword: undefined; - KakaoOnboarding: undefined; - Main: undefined; - Chatbot: undefined; - Diet: undefined; - MealAdd: { selectedFood?: any } | undefined; - FoodSearch: undefined; - MealDetail: { mealId?: string } | undefined; - ExerciseDetail: { exerciseId?: string } | undefined; - Calendar: undefined; - Goal: undefined; - Exercise: undefined; - Analysis: undefined; - Graph: undefined; - MealRecommend: undefined; - MealRecommendHistory: undefined; - TempMealRecommend: undefined; - RoutineRecommend: undefined; - RoutineRecommendNew: undefined; - TempRoutineRecommendScreen: undefined; - InBody: undefined; - FoodAddOptions: undefined; - InBodyManual: undefined; - HealthScoreTrend: undefined; - // Payment - PaymentSuccess: { sessionId?: string; orderId?: string }; - PaymentFail: undefined; - PaymentCancel: undefined; -}; - -export type MainTabParamList = { - Home: undefined; - Stats: undefined; - Chatbot: undefined; - Analysis: undefined; - MyPage: { openPremiumModal?: boolean } | undefined; -}; +export type RootStackParamList = { + Splash: undefined; + Onboarding: undefined; + Login: undefined; + Signup: undefined; + FindId: undefined; + ResetPassword: undefined; + KakaoOnboarding: undefined; + Main: undefined; + Chatbot: undefined; + Diet: undefined; + MealAdd: { selectedFood?: any } | undefined; + FoodSearch: undefined; + MealDetail: { mealId?: string } | undefined; + ExerciseDetail: { exerciseId?: string } | undefined; + Calendar: undefined; + Goal: undefined; + Exercise: undefined; + Analysis: undefined; + Graph: undefined; + MealRecommend: undefined; + MealRecommendHistory: undefined; + TempMealRecommend: undefined; + RoutineRecommend: undefined; + RoutineRecommendNew: undefined; + TempRoutineRecommendScreen: undefined; + InBody: undefined; + FoodAddOptions: undefined; + InBodyManual: undefined; + HealthScoreTrend: undefined; + // Payment + PaymentSuccess: { sessionId?: string; orderId?: string }; + PaymentFail: undefined; + PaymentCancel: undefined; +}; + +export type MainTabParamList = { + Home: undefined; + Stats: undefined; + Chatbot: undefined; + Analysis: undefined; + MyPage: { openPremiumModal?: boolean } | undefined; +}; diff --git a/src/screens/auth/KakaoOnboardingScreen.tsx b/src/screens/auth/KakaoOnboardingScreen.tsx index 854b9a5..4113d73 100644 --- a/src/screens/auth/KakaoOnboardingScreen.tsx +++ b/src/screens/auth/KakaoOnboardingScreen.tsx @@ -1,906 +1,906 @@ -import React, {useState} from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - StyleSheet, - KeyboardAvoidingView, - Platform, - ScrollView, - Alert, - ActivityIndicator, - Modal, -} from 'react-native'; -import {Picker} from '@react-native-picker/picker'; -import {authAPI} from '../../services'; - -const healthGoalOptions = [ - {label: '벌크업', value: 'BULK'}, - {label: '다이어트', value: 'DIET'}, - {label: '린매스업', value: 'LEAN_MASS'}, - {label: '유지', value: 'MAINTENANCE'}, - {label: '유연성 향상', value: 'FLEXIBILITY'}, - {label: '체력증진', value: 'ENDURANCE'}, - {label: '자세 교정', value: 'POSTURE'}, - {label: '기타', value: 'OTHER'}, -]; - -const workoutDaysOptions = [ - {label: '주 1회', value: '주 1회'}, - {label: '주 2회', value: '주 2회'}, - {label: '주 3회', value: '주 3회'}, - {label: '주 4회', value: '주 4회'}, - {label: '주 5회', value: '주 5회'}, - {label: '주 6회', value: '주 6회'}, - {label: '주 7회', value: '주 7회'}, -]; - -const experienceLevelOptions = [ - {label: '초보자', value: 'BEGINNER'}, - {label: '중급자', value: 'INTERMEDIATE'}, - {label: '고급자', value: 'ADVANCED'}, -]; - -const KakaoOnboardingScreen = ({navigation}: any) => { - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - birthYear: '', - birthMonth: '', - birthDay: '', - gender: '' as 'M' | 'F' | '', - height: '', - weight: '', - weightGoal: '', - healthGoal: '', - workoutDaysPerWeek: '', - experienceLevel: '', - fitnessConcerns: '', - }); - const [errors, setErrors] = useState({}); - const [pickerModalVisible, setPickerModalVisible] = useState(false); - const [tempPickerValue, setTempPickerValue] = useState({year: '', month: '', day: ''}); - const [genderModalVisible, setGenderModalVisible] = useState(false); - const [workoutDaysModalVisible, setWorkoutDaysModalVisible] = useState(false); - const [healthGoalModalVisible, setHealthGoalModalVisible] = useState(false); - const [experienceLevelModalVisible, setExperienceLevelModalVisible] = useState(false); - - const handleChange = (name: string, value: string) => { - setFormData(prev => ({...prev, [name]: value})); - if (errors[name]) { - setErrors((prev: any) => ({...prev, [name]: ''})); - } - }; - - const generateYearOptions = () => { - const currentYear = new Date().getFullYear(); - const years = []; - for (let i = currentYear; i >= currentYear - 100; i--) { - years.push(i); - } - return years; - }; - - const generateMonthOptions = () => { - return Array.from({length: 12}, (_, i) => i + 1); - }; - - const validateForm = () => { - const newErrors: any = {}; - - if (!formData.birthYear || !formData.birthMonth || !formData.birthDay) { - newErrors.birth = '생년월일을 선택해주세요'; - } - - if (!formData.gender) { - newErrors.gender = '성별을 선택해주세요'; - } - - if (!formData.height.trim()) { - newErrors.height = '키를 입력해주세요'; - } else { - const heightNum = Number(formData.height); - if (heightNum < 100 || heightNum > 250) { - newErrors.height = '키는 100cm 이상 250cm 이하여야 합니다'; - } - } - - if (!formData.weight.trim()) { - newErrors.weight = '체중을 입력해주세요'; - } else { - const weightNum = Number(formData.weight); - if (weightNum < 30 || weightNum > 200) { - newErrors.weight = '체중은 30kg 이상 200kg 이하여야 합니다'; - } - } - - if (!formData.weightGoal.trim()) { - newErrors.weightGoal = '목표 체중을 입력해주세요'; - } else { - const weightGoalNum = Number(formData.weightGoal); - if (weightGoalNum < 30 || weightGoalNum > 200) { - newErrors.weightGoal = '목표 체중은 30kg 이상 200kg 이하여야 합니다'; - } - } - - if (!formData.healthGoal) { - newErrors.healthGoal = '헬스 목적을 선택해주세요'; - } - - if (!formData.workoutDaysPerWeek) { - newErrors.workoutDaysPerWeek = '주간 운동 횟수를 선택해주세요'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async () => { - if (!validateForm()) { - return; - } - - setLoading(true); - try { - const birthDate = `${formData.birthYear}-${String(formData.birthMonth).padStart(2, '0')}-${String(formData.birthDay).padStart(2, '0')}`; - - const onboardingData = { - gender: formData.gender, - height: Number(formData.height), - weight: Number(formData.weight), - birthDate, - weightGoal: Number(formData.weightGoal), - healthGoal: formData.healthGoal, - workoutDaysPerWeek: formData.workoutDaysPerWeek, - ...(formData.experienceLevel && {experienceLevel: formData.experienceLevel}), - ...(formData.fitnessConcerns && {fitnessConcerns: formData.fitnessConcerns}), - }; - - const response = await authAPI.submitOnboarding(onboardingData); - - if (response.success) { - Alert.alert('완료', '신체정보가 저장되었습니다.', [ - { - text: '확인', - onPress: () => navigation.replace('Main'), - }, - ]); - } else { - Alert.alert('오류', response.message || '신체정보 저장에 실패했습니다.'); - } - } catch (error: any) { - console.error('온보딩 제출 실패:', error); - Alert.alert('오류', error.message || '신체정보 저장에 실패했습니다.'); - } finally { - setLoading(false); - } - }; - - return ( - - - - 신체정보를 입력해주세요 - 맞춤형 서비스를 제공하기 위해 필요합니다 - - - - {/* 생년월일 */} - - { - const currentYear = new Date().getFullYear(); - const defaultYear = formData.birthYear || String(currentYear - 20); - const defaultMonth = formData.birthMonth || '1'; - const defaultDay = formData.birthDay || '1'; - - setTempPickerValue({ - year: defaultYear, - month: defaultMonth, - day: defaultDay, - }); - setPickerModalVisible(true); - }}> - - - {errors.birth && ( - {errors.birth} - )} - - - {/* 생년월일 선택 모달 */} - setPickerModalVisible(false)}> - setPickerModalVisible(false)}> - {}} - style={styles.modalContent}> - - setPickerModalVisible(false)}> - 취소 - - 생년월일 선택 - { - handleChange('birthYear', tempPickerValue.year); - handleChange('birthMonth', tempPickerValue.month); - handleChange('birthDay', tempPickerValue.day); - setPickerModalVisible(false); - }}> - 확인 - - - - - - { - const newValue = {...tempPickerValue, year: value}; - if (newValue.year && newValue.month) { - const daysInMonth = new Date( - Number(newValue.year), - Number(newValue.month), - 0 - ).getDate(); - const currentDay = Number(newValue.day) || 1; - if (currentDay > daysInMonth) { - newValue.day = '1'; - } - } - setTempPickerValue(newValue); - }} - style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> - {generateYearOptions().map(year => ( - - ))} - - - - - { - const newValue = {...tempPickerValue, month: value}; - if (newValue.year && newValue.month) { - const daysInMonth = new Date( - Number(newValue.year), - Number(newValue.month), - 0 - ).getDate(); - const currentDay = Number(newValue.day) || 1; - if (currentDay > daysInMonth) { - newValue.day = '1'; - } - } - setTempPickerValue(newValue); - }} - style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> - {generateMonthOptions().map(month => ( - - ))} - - - - - { - setTempPickerValue({...tempPickerValue, day: value}); - }} - style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> - {(() => { - if (tempPickerValue.year && tempPickerValue.month) { - const year = Number(tempPickerValue.year); - const month = Number(tempPickerValue.month); - const daysInMonth = new Date(year, month, 0).getDate(); - const days = Array.from({length: daysInMonth}, (_, i) => i + 1); - - const currentDay = Number(tempPickerValue.day) || 1; - if (currentDay > daysInMonth) { - setTimeout(() => { - setTempPickerValue({...tempPickerValue, day: '1'}); - }, 0); - } - - return days; - } - return Array.from({length: 31}, (_, i) => i + 1); - })().map(day => ( - - ))} - - - - - - - - {/* 성별 */} - - setGenderModalVisible(true)}> - - - {errors.gender && ( - {errors.gender} - )} - - - {/* 성별 선택 모달 */} - setGenderModalVisible(false)}> - setGenderModalVisible(false)}> - {}} - style={styles.modalContent}> - - setGenderModalVisible(false)}> - 취소 - - 성별 선택 - - - - { - handleChange('gender', 'M'); - setGenderModalVisible(false); - }}> - - 남성 - - - { - handleChange('gender', 'F'); - setGenderModalVisible(false); - }}> - - 여성 - - - - - - - - {/* 키, 체중 */} - - - handleChange('height', text)} - keyboardType="number-pad" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - /> - {errors.height && ( - {errors.height} - )} - - - - handleChange('weight', text)} - keyboardType="number-pad" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - /> - {errors.weight && ( - {errors.weight} - )} - - - - {/* 목표 체중 */} - - handleChange('weightGoal', text)} - keyboardType="number-pad" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - /> - {errors.weightGoal && ( - {errors.weightGoal} - )} - - - {/* 헬스 목적 */} - - setHealthGoalModalVisible(true)}> - opt.value === formData.healthGoal)?.label || '' - : '' - } - placeholder="헬스 목적" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - editable={false} - pointerEvents="none" - /> - - {errors.healthGoal && ( - {errors.healthGoal} - )} - - - {/* 헬스 목적 선택 모달 */} - setHealthGoalModalVisible(false)}> - setHealthGoalModalVisible(false)}> - {}} - style={styles.modalContent}> - - setHealthGoalModalVisible(false)}> - 취소 - - 헬스 목적 선택 - - - - - {healthGoalOptions.map((option) => ( - { - handleChange('healthGoal', option.value); - setHealthGoalModalVisible(false); - }}> - - {option.label} - - - ))} - - - - - - - {/* 주간 운동 횟수 */} - - setWorkoutDaysModalVisible(true)}> - opt.value === formData.workoutDaysPerWeek)?.label || '' - : '' - } - placeholder="주간 운동 횟수" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - editable={false} - pointerEvents="none" - /> - - {errors.workoutDaysPerWeek && ( - {errors.workoutDaysPerWeek} - )} - - - {/* 주간 운동 횟수 선택 모달 */} - setWorkoutDaysModalVisible(false)}> - setWorkoutDaysModalVisible(false)}> - {}} - style={styles.modalContent}> - - setWorkoutDaysModalVisible(false)}> - 취소 - - 주간 운동 횟수 선택 - - - - - {workoutDaysOptions.map((option) => ( - { - handleChange('workoutDaysPerWeek', option.value); - setWorkoutDaysModalVisible(false); - }}> - - {option.label} - - - ))} - - - - - - - {/* 운동 경험 수준 (선택) */} - - setExperienceLevelModalVisible(true)}> - opt.value === formData.experienceLevel)?.label || '' - : '' - } - placeholder="운동 경험 수준 (선택)" - placeholderTextColor="rgba(255, 255, 255, 0.7)" - editable={false} - pointerEvents="none" - /> - - - - {/* 운동 경험 수준 선택 모달 */} - setExperienceLevelModalVisible(false)}> - setExperienceLevelModalVisible(false)}> - {}} - style={styles.modalContent}> - - setExperienceLevelModalVisible(false)}> - 취소 - - 운동 경험 수준 선택 - - - - - {experienceLevelOptions.map((option) => ( - { - handleChange('experienceLevel', option.value); - setExperienceLevelModalVisible(false); - }}> - - {option.label} - - - ))} - - - - - - - {/* 헬스 고민 (선택) */} - - handleChange('fitnessConcerns', text)} - placeholderTextColor="rgba(255, 255, 255, 0.7)" - multiline - /> - - - - - {loading ? ( - - ) : ( - 완료 - )} - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#252525', - }, - scrollContent: { - flexGrow: 1, - paddingHorizontal: 20, - paddingTop: 60, - paddingBottom: 40, - }, - header: { - marginBottom: 30, - }, - title: { - fontSize: 24, - fontWeight: '700', - color: '#ffffff', - marginBottom: 8, - }, - subtitle: { - fontSize: 14, - color: 'rgba(255, 255, 255, 0.7)', - }, - form: { - marginBottom: 30, - }, - inputGroup: { - marginBottom: 20, - }, - inputRow: { - flexDirection: 'row', - gap: 12, - }, - inputHalf: { - flex: 1, - }, - input: { - width: '100%', - height: 60, - backgroundColor: '#434343', - borderWidth: 0, - borderRadius: 20, - paddingHorizontal: 20, - fontSize: 16, - fontWeight: '400', - color: '#ffffff', - }, - birthDateButtonContainer: { - width: '100%', - }, - errorMessage: { - color: '#ff6b6b', - fontSize: 14, - marginTop: 5, - marginLeft: 5, - }, - submitBtn: { - width: '100%', - height: 60, - backgroundColor: '#ffffff', - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - }, - submitBtnDisabled: { - opacity: 0.6, - }, - submitBtnText: { - color: '#000000', - fontSize: 18, - fontWeight: '700', - }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: '#252525', - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - paddingBottom: 40, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - paddingVertical: 16, - borderBottomWidth: 1, - borderBottomColor: '#434343', - }, - modalTitle: { - fontSize: 18, - fontWeight: '700', - color: '#ffffff', - }, - modalCancelText: { - fontSize: 16, - color: 'rgba(255, 255, 255, 0.7)', - }, - modalConfirmText: { - fontSize: 16, - color: '#ffffff', - fontWeight: '600', - }, - birthPickerGroup: { - flexDirection: 'row', - paddingHorizontal: 20, - paddingVertical: 20, - }, - birthPickerItem: { - flex: 1, - alignItems: 'center', - }, - birthPickerLabelTop: { - fontSize: 14, - color: 'rgba(255, 255, 255, 0.7)', - marginBottom: 8, - }, - modalPicker: { - width: '100%', - height: 150, - }, - pickerItemStyle: { - color: '#ffffff', - }, - genderOptionContainer: { - paddingHorizontal: 20, - paddingVertical: 20, - }, - genderOption: { - paddingVertical: 16, - paddingHorizontal: 20, - backgroundColor: '#434343', - borderRadius: 12, - marginBottom: 12, - alignItems: 'center', - }, - genderOptionSelected: { - backgroundColor: '#ffffff', - }, - genderOptionText: { - fontSize: 16, - color: '#ffffff', - }, - genderOptionTextSelected: { - color: '#000000', - fontWeight: '600', - }, - modalOptionContainer: { - paddingHorizontal: 20, - paddingVertical: 20, - }, - optionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 12, - }, - optionButton: { - flex: 1, - minWidth: '45%', - paddingVertical: 16, - paddingHorizontal: 20, - backgroundColor: '#434343', - borderRadius: 12, - alignItems: 'center', - }, - optionButtonSelected: { - backgroundColor: '#ffffff', - }, - optionButtonText: { - fontSize: 16, - color: '#ffffff', - }, - optionButtonTextSelected: { - color: '#000000', - fontWeight: '600', - }, -}); - -export default KakaoOnboardingScreen; - +import React, {useState} from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, + Alert, + ActivityIndicator, + Modal, +} from 'react-native'; +import {Picker} from '@react-native-picker/picker'; +import {authAPI} from '../../services'; + +const healthGoalOptions = [ + {label: '벌크업', value: 'BULK'}, + {label: '다이어트', value: 'DIET'}, + {label: '린매스업', value: 'LEAN_MASS'}, + {label: '유지', value: 'MAINTENANCE'}, + {label: '유연성 향상', value: 'FLEXIBILITY'}, + {label: '체력증진', value: 'ENDURANCE'}, + {label: '자세 교정', value: 'POSTURE'}, + {label: '기타', value: 'OTHER'}, +]; + +const workoutDaysOptions = [ + {label: '주 1회', value: '주 1회'}, + {label: '주 2회', value: '주 2회'}, + {label: '주 3회', value: '주 3회'}, + {label: '주 4회', value: '주 4회'}, + {label: '주 5회', value: '주 5회'}, + {label: '주 6회', value: '주 6회'}, + {label: '주 7회', value: '주 7회'}, +]; + +const experienceLevelOptions = [ + {label: '초보자', value: 'BEGINNER'}, + {label: '중급자', value: 'INTERMEDIATE'}, + {label: '고급자', value: 'ADVANCED'}, +]; + +const KakaoOnboardingScreen = ({navigation}: any) => { + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + birthYear: '', + birthMonth: '', + birthDay: '', + gender: '' as 'M' | 'F' | '', + height: '', + weight: '', + weightGoal: '', + healthGoal: '', + workoutDaysPerWeek: '', + experienceLevel: '', + fitnessConcerns: '', + }); + const [errors, setErrors] = useState({}); + const [pickerModalVisible, setPickerModalVisible] = useState(false); + const [tempPickerValue, setTempPickerValue] = useState({year: '', month: '', day: ''}); + const [genderModalVisible, setGenderModalVisible] = useState(false); + const [workoutDaysModalVisible, setWorkoutDaysModalVisible] = useState(false); + const [healthGoalModalVisible, setHealthGoalModalVisible] = useState(false); + const [experienceLevelModalVisible, setExperienceLevelModalVisible] = useState(false); + + const handleChange = (name: string, value: string) => { + setFormData(prev => ({...prev, [name]: value})); + if (errors[name]) { + setErrors((prev: any) => ({...prev, [name]: ''})); + } + }; + + const generateYearOptions = () => { + const currentYear = new Date().getFullYear(); + const years = []; + for (let i = currentYear; i >= currentYear - 100; i--) { + years.push(i); + } + return years; + }; + + const generateMonthOptions = () => { + return Array.from({length: 12}, (_, i) => i + 1); + }; + + const validateForm = () => { + const newErrors: any = {}; + + if (!formData.birthYear || !formData.birthMonth || !formData.birthDay) { + newErrors.birth = '생년월일을 선택해주세요'; + } + + if (!formData.gender) { + newErrors.gender = '성별을 선택해주세요'; + } + + if (!formData.height.trim()) { + newErrors.height = '키를 입력해주세요'; + } else { + const heightNum = Number(formData.height); + if (heightNum < 100 || heightNum > 250) { + newErrors.height = '키는 100cm 이상 250cm 이하여야 합니다'; + } + } + + if (!formData.weight.trim()) { + newErrors.weight = '체중을 입력해주세요'; + } else { + const weightNum = Number(formData.weight); + if (weightNum < 30 || weightNum > 200) { + newErrors.weight = '체중은 30kg 이상 200kg 이하여야 합니다'; + } + } + + if (!formData.weightGoal.trim()) { + newErrors.weightGoal = '목표 체중을 입력해주세요'; + } else { + const weightGoalNum = Number(formData.weightGoal); + if (weightGoalNum < 30 || weightGoalNum > 200) { + newErrors.weightGoal = '목표 체중은 30kg 이상 200kg 이하여야 합니다'; + } + } + + if (!formData.healthGoal) { + newErrors.healthGoal = '헬스 목적을 선택해주세요'; + } + + if (!formData.workoutDaysPerWeek) { + newErrors.workoutDaysPerWeek = '주간 운동 횟수를 선택해주세요'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setLoading(true); + try { + const birthDate = `${formData.birthYear}-${String(formData.birthMonth).padStart(2, '0')}-${String(formData.birthDay).padStart(2, '0')}`; + + const onboardingData = { + gender: formData.gender, + height: Number(formData.height), + weight: Number(formData.weight), + birthDate, + weightGoal: Number(formData.weightGoal), + healthGoal: formData.healthGoal, + workoutDaysPerWeek: formData.workoutDaysPerWeek, + ...(formData.experienceLevel && {experienceLevel: formData.experienceLevel}), + ...(formData.fitnessConcerns && {fitnessConcerns: formData.fitnessConcerns}), + }; + + const response = await authAPI.submitOnboarding(onboardingData); + + if (response.success) { + Alert.alert('완료', '신체정보가 저장되었습니다.', [ + { + text: '확인', + onPress: () => navigation.replace('Main'), + }, + ]); + } else { + Alert.alert('오류', response.message || '신체정보 저장에 실패했습니다.'); + } + } catch (error: any) { + console.error('온보딩 제출 실패:', error); + Alert.alert('오류', error.message || '신체정보 저장에 실패했습니다.'); + } finally { + setLoading(false); + } + }; + + return ( + + + + 신체정보를 입력해주세요 + 맞춤형 서비스를 제공하기 위해 필요합니다 + + + + {/* 생년월일 */} + + { + const currentYear = new Date().getFullYear(); + const defaultYear = formData.birthYear || String(currentYear - 20); + const defaultMonth = formData.birthMonth || '1'; + const defaultDay = formData.birthDay || '1'; + + setTempPickerValue({ + year: defaultYear, + month: defaultMonth, + day: defaultDay, + }); + setPickerModalVisible(true); + }}> + + + {errors.birth && ( + {errors.birth} + )} + + + {/* 생년월일 선택 모달 */} + setPickerModalVisible(false)}> + setPickerModalVisible(false)}> + {}} + style={styles.modalContent}> + + setPickerModalVisible(false)}> + 취소 + + 생년월일 선택 + { + handleChange('birthYear', tempPickerValue.year); + handleChange('birthMonth', tempPickerValue.month); + handleChange('birthDay', tempPickerValue.day); + setPickerModalVisible(false); + }}> + 확인 + + + + + + { + const newValue = {...tempPickerValue, year: value}; + if (newValue.year && newValue.month) { + const daysInMonth = new Date( + Number(newValue.year), + Number(newValue.month), + 0 + ).getDate(); + const currentDay = Number(newValue.day) || 1; + if (currentDay > daysInMonth) { + newValue.day = '1'; + } + } + setTempPickerValue(newValue); + }} + style={styles.modalPicker} + itemStyle={styles.pickerItemStyle}> + {generateYearOptions().map(year => ( + + ))} + + + + + { + const newValue = {...tempPickerValue, month: value}; + if (newValue.year && newValue.month) { + const daysInMonth = new Date( + Number(newValue.year), + Number(newValue.month), + 0 + ).getDate(); + const currentDay = Number(newValue.day) || 1; + if (currentDay > daysInMonth) { + newValue.day = '1'; + } + } + setTempPickerValue(newValue); + }} + style={styles.modalPicker} + itemStyle={styles.pickerItemStyle}> + {generateMonthOptions().map(month => ( + + ))} + + + + + { + setTempPickerValue({...tempPickerValue, day: value}); + }} + style={styles.modalPicker} + itemStyle={styles.pickerItemStyle}> + {(() => { + if (tempPickerValue.year && tempPickerValue.month) { + const year = Number(tempPickerValue.year); + const month = Number(tempPickerValue.month); + const daysInMonth = new Date(year, month, 0).getDate(); + const days = Array.from({length: daysInMonth}, (_, i) => i + 1); + + const currentDay = Number(tempPickerValue.day) || 1; + if (currentDay > daysInMonth) { + setTimeout(() => { + setTempPickerValue({...tempPickerValue, day: '1'}); + }, 0); + } + + return days; + } + return Array.from({length: 31}, (_, i) => i + 1); + })().map(day => ( + + ))} + + + + + + + + {/* 성별 */} + + setGenderModalVisible(true)}> + + + {errors.gender && ( + {errors.gender} + )} + + + {/* 성별 선택 모달 */} + setGenderModalVisible(false)}> + setGenderModalVisible(false)}> + {}} + style={styles.modalContent}> + + setGenderModalVisible(false)}> + 취소 + + 성별 선택 + + + + { + handleChange('gender', 'M'); + setGenderModalVisible(false); + }}> + + 남성 + + + { + handleChange('gender', 'F'); + setGenderModalVisible(false); + }}> + + 여성 + + + + + + + + {/* 키, 체중 */} + + + handleChange('height', text)} + keyboardType="number-pad" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + /> + {errors.height && ( + {errors.height} + )} + + + + handleChange('weight', text)} + keyboardType="number-pad" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + /> + {errors.weight && ( + {errors.weight} + )} + + + + {/* 목표 체중 */} + + handleChange('weightGoal', text)} + keyboardType="number-pad" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + /> + {errors.weightGoal && ( + {errors.weightGoal} + )} + + + {/* 헬스 목적 */} + + setHealthGoalModalVisible(true)}> + opt.value === formData.healthGoal)?.label || '' + : '' + } + placeholder="헬스 목적" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + editable={false} + pointerEvents="none" + /> + + {errors.healthGoal && ( + {errors.healthGoal} + )} + + + {/* 헬스 목적 선택 모달 */} + setHealthGoalModalVisible(false)}> + setHealthGoalModalVisible(false)}> + {}} + style={styles.modalContent}> + + setHealthGoalModalVisible(false)}> + 취소 + + 헬스 목적 선택 + + + + + {healthGoalOptions.map((option) => ( + { + handleChange('healthGoal', option.value); + setHealthGoalModalVisible(false); + }}> + + {option.label} + + + ))} + + + + + + + {/* 주간 운동 횟수 */} + + setWorkoutDaysModalVisible(true)}> + opt.value === formData.workoutDaysPerWeek)?.label || '' + : '' + } + placeholder="주간 운동 횟수" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + editable={false} + pointerEvents="none" + /> + + {errors.workoutDaysPerWeek && ( + {errors.workoutDaysPerWeek} + )} + + + {/* 주간 운동 횟수 선택 모달 */} + setWorkoutDaysModalVisible(false)}> + setWorkoutDaysModalVisible(false)}> + {}} + style={styles.modalContent}> + + setWorkoutDaysModalVisible(false)}> + 취소 + + 주간 운동 횟수 선택 + + + + + {workoutDaysOptions.map((option) => ( + { + handleChange('workoutDaysPerWeek', option.value); + setWorkoutDaysModalVisible(false); + }}> + + {option.label} + + + ))} + + + + + + + {/* 운동 경험 수준 (선택) */} + + setExperienceLevelModalVisible(true)}> + opt.value === formData.experienceLevel)?.label || '' + : '' + } + placeholder="운동 경험 수준 (선택)" + placeholderTextColor="rgba(255, 255, 255, 0.7)" + editable={false} + pointerEvents="none" + /> + + + + {/* 운동 경험 수준 선택 모달 */} + setExperienceLevelModalVisible(false)}> + setExperienceLevelModalVisible(false)}> + {}} + style={styles.modalContent}> + + setExperienceLevelModalVisible(false)}> + 취소 + + 운동 경험 수준 선택 + + + + + {experienceLevelOptions.map((option) => ( + { + handleChange('experienceLevel', option.value); + setExperienceLevelModalVisible(false); + }}> + + {option.label} + + + ))} + + + + + + + {/* 헬스 고민 (선택) */} + + handleChange('fitnessConcerns', text)} + placeholderTextColor="rgba(255, 255, 255, 0.7)" + multiline + /> + + + + + {loading ? ( + + ) : ( + 완료 + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#252525', + }, + scrollContent: { + flexGrow: 1, + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 40, + }, + header: { + marginBottom: 30, + }, + title: { + fontSize: 24, + fontWeight: '700', + color: '#ffffff', + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + color: 'rgba(255, 255, 255, 0.7)', + }, + form: { + marginBottom: 30, + }, + inputGroup: { + marginBottom: 20, + }, + inputRow: { + flexDirection: 'row', + gap: 12, + }, + inputHalf: { + flex: 1, + }, + input: { + width: '100%', + height: 60, + backgroundColor: '#434343', + borderWidth: 0, + borderRadius: 20, + paddingHorizontal: 20, + fontSize: 16, + fontWeight: '400', + color: '#ffffff', + }, + birthDateButtonContainer: { + width: '100%', + }, + errorMessage: { + color: '#ff6b6b', + fontSize: 14, + marginTop: 5, + marginLeft: 5, + }, + submitBtn: { + width: '100%', + height: 60, + backgroundColor: '#ffffff', + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + submitBtnDisabled: { + opacity: 0.6, + }, + submitBtnText: { + color: '#000000', + fontSize: 18, + fontWeight: '700', + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: '#252525', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingBottom: 40, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: '#434343', + }, + modalTitle: { + fontSize: 18, + fontWeight: '700', + color: '#ffffff', + }, + modalCancelText: { + fontSize: 16, + color: 'rgba(255, 255, 255, 0.7)', + }, + modalConfirmText: { + fontSize: 16, + color: '#ffffff', + fontWeight: '600', + }, + birthPickerGroup: { + flexDirection: 'row', + paddingHorizontal: 20, + paddingVertical: 20, + }, + birthPickerItem: { + flex: 1, + alignItems: 'center', + }, + birthPickerLabelTop: { + fontSize: 14, + color: 'rgba(255, 255, 255, 0.7)', + marginBottom: 8, + }, + modalPicker: { + width: '100%', + height: 150, + }, + pickerItemStyle: { + color: '#ffffff', + }, + genderOptionContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + genderOption: { + paddingVertical: 16, + paddingHorizontal: 20, + backgroundColor: '#434343', + borderRadius: 12, + marginBottom: 12, + alignItems: 'center', + }, + genderOptionSelected: { + backgroundColor: '#ffffff', + }, + genderOptionText: { + fontSize: 16, + color: '#ffffff', + }, + genderOptionTextSelected: { + color: '#000000', + fontWeight: '600', + }, + modalOptionContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + optionGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + optionButton: { + flex: 1, + minWidth: '45%', + paddingVertical: 16, + paddingHorizontal: 20, + backgroundColor: '#434343', + borderRadius: 12, + alignItems: 'center', + }, + optionButtonSelected: { + backgroundColor: '#ffffff', + }, + optionButtonText: { + fontSize: 16, + color: '#ffffff', + }, + optionButtonTextSelected: { + color: '#000000', + fontWeight: '600', + }, +}); + +export default KakaoOnboardingScreen; + diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index f6a4f77..cfd2dc2 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -1,651 +1,651 @@ -import React, {useState, useEffect} from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - StyleSheet, - KeyboardAvoidingView, - Platform, - ScrollView, - Alert, - ActivityIndicator, -} from 'react-native'; -import * as Linking from 'expo-linking'; -import * as WebBrowser from 'expo-web-browser'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import {colors} from '../../theme/colors'; -import {authAPI} from '../../services'; -import {ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY} from '../../services/apiConfig'; - -// @react-native-seoul/kakao-login (네이티브 빌드에서만 작동) -// Expo Go에서는 WebBrowser.openAuthSessionAsync 사용 -let KakaoLogin: any = null; -try { - KakaoLogin = require('@react-native-seoul/kakao-login'); -} catch (e) { - console.log('카카오 로그인 네이티브 모듈을 사용할 수 없습니다 (Expo Go 환경일 수 있음)'); -} - -const LoginScreen = ({navigation}: any) => { - const [formData, setFormData] = useState({ - username: '', - password: '', - }); - const [errors, setErrors] = useState<{username?: string; password?: string}>({}); - const [loading, setLoading] = useState(false); - - const handleChange = (name: string, value: string) => { - setFormData(prev => ({ - ...prev, - [name]: value, - })); - if (errors[name as keyof typeof errors]) { - setErrors(prev => ({ - ...prev, - [name]: '', - })); - } - }; - - const validateForm = () => { - const newErrors: {username?: string; password?: string} = {}; - - if (!formData.username.trim()) { - newErrors.username = '아이디를 입력해주세요'; - } - - if (!formData.password.trim()) { - newErrors.password = '비밀번호를 입력해주세요'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = async () => { - if (!validateForm()) { - return; - } - - setLoading(true); - try { - const response = await authAPI.login(formData.username, formData.password); - - if (response.success && response.accessToken) { - navigation.replace('Main'); - } else { - const errorMessage = response.message || '로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { - (window as any).alert(`로그인 실패\n${errorMessage}`); - } else { - Alert.alert('로그인 실패', errorMessage); - } - } - } catch (error: any) { - const errorMessage = error.message || '로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { - (window as any).alert(`로그인 실패\n${errorMessage}`); - } else { - Alert.alert('로그인 실패', errorMessage); - } - } finally { - setLoading(false); - } - }; - - useEffect(() => { - // 🔹 ② redirect_uri 가로채기 (가장 중요) - const subscription = Linking.addEventListener('url', async ({ url }) => { - console.log('🔗 [카카오 로그인] 딥링크 수신:', url); - - const parsed = Linking.parse(url); - const code = parsed.queryParams?.code as string | undefined; - const accessToken = parsed.queryParams?.accessToken as string | undefined; - const refreshToken = parsed.queryParams?.refreshToken as string | undefined; - - // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 - if (accessToken) { - console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); - try { - setLoading(true); - - // 토큰 저장 - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, accessToken); - if (refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - } - - // userId가 있으면 저장 - const userId = parsed.queryParams?.userId as string | undefined; - if (userId) { - await AsyncStorage.setItem('userId', userId); - } - - // membershipType 저장 (기본값 FREE) - const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; - await AsyncStorage.setItem('membershipType', membershipType); - - // 온보딩 여부 확인 - const isOnboarded = parsed.queryParams?.isOnboarded; - const onboarded = parsed.queryParams?.onboarded; - const shouldOnboard = isOnboarded === 'false' || onboarded === 'false'; - - console.log('✅ [카카오 로그인] 토큰 저장 완료'); - if (shouldOnboard) { - navigation.replace('KakaoOnboarding'); - } else { - navigation.replace('Main'); - } - } catch (error: any) { - console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); - Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - return; - } - - // 인증 코드가 있는 경우 (백엔드 API 호출 필요) - if (!code) { - console.log('⚠️ [카카오 로그인] 딥링크에 code 또는 accessToken이 없음'); - return; - } - - console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); - try { - setLoading(true); - - // 👉 여기서 서버 API 호출 - const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - } - ); - - console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); - const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); - - if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); - } - - // 토큰 저장 - if (data.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); - } - if (data.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); - } - if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); - } - if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); - } else { - await AsyncStorage.setItem('membershipType', 'FREE'); - } - - // 온보딩 여부 확인 (isOnboarded 또는 onboarded 값 확인) - const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; - if (isOnboarded === false) { - navigation.replace('KakaoOnboarding'); - } else { - navigation.replace('Main'); - } - } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); - const errorMessage = error.message || '카카오 로그인에 실패했습니다'; - if (Platform.OS === 'web') { - window.alert(`로그인 실패\n${errorMessage}`); - } else { - Alert.alert('로그인 실패', errorMessage); - } - } finally { - setLoading(false); - } - }); - - // 앱이 이미 열려있을 때 딥링크 처리 - Linking.getInitialURL().then(async (url) => { - if (url) { - const parsed = Linking.parse(url); - const code = parsed.queryParams?.code as string | undefined; - if (code) { - // 초기 URL에 코드가 있으면 처리 - try { - setLoading(true); - - const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - } - ); - - const data = await res.json(); - - if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); - } - - // 토큰 저장 - if (data.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); - } - if (data.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); - } - if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); - } else { - await AsyncStorage.setItem('membershipType', 'FREE'); - } - - // 메인 화면으로 이동 - navigation.replace('Main'); - } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); - } finally { - setLoading(false); - } - } - } - }); - - return () => { - subscription.remove(); - }; - }, [navigation]); - - const handleKakaoLogin = async () => { - try { - setLoading(true); - console.log('🔵 [카카오 로그인] 시작'); - - // 웹 환경 체크 (Platform.OS가 'web'인 경우) - if (Platform.OS === 'web' && typeof window !== 'undefined' && window.open) { - const KAKAO_CLIENT_ID = '99baee411cc547822f138712b19b032c'; - const REDIRECT_URI = 'https://www.intelfits.com/api/auth/kakao/callback'; - const loginUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=profile_nickname,profile_image`; - // 웹에서는 새 창으로 열기 - window.open(loginUrl, '_blank'); - setLoading(false); - return; - } - - // ✅ 방법 1: @react-native-seoul/kakao-login 사용 (네이티브 빌드에서만 작동) - if (KakaoLogin && KakaoLogin.login) { - try { - console.log('🔵 [카카오 로그인] 네이티브 모듈 사용'); - const token = await KakaoLogin.login(); - console.log('✅ [카카오 로그인] 토큰 받음:', token); - - // 카카오 액세스 토큰을 백엔드로 전송하여 JWT 토큰 발급받기 - // 또는 카카오 프로필 정보를 가져와서 백엔드에 전송 - const profile = await KakaoLogin.getProfile(); - console.log('✅ [카카오 로그인] 프로필:', profile); - - // 백엔드 API 호출 (카카오 ID로 로그인/회원가입) - const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - kakaoAccessToken: token.accessToken, - kakaoId: profile.id, - nickname: profile.nickname, - profileImage: profile.profileImageUrl - }), - } - ); - - const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답:', data); - - if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); - } - - // 토큰 저장 - if (data.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); - } - if (data.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); - } - if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); - } - if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); - } else { - await AsyncStorage.setItem('membershipType', 'FREE'); - } - - // 온보딩 여부 확인 (isOnboarded 또는 onboarded 값 확인) - const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; - if (isOnboarded === false) { - navigation.replace('KakaoOnboarding'); - } else { - navigation.replace('Main'); - } - return; - } catch (nativeError: any) { - console.log('⚠️ [카카오 로그인] 네이티브 모듈 실패, WebBrowser 방식으로 전환:', nativeError.message); - // 네이티브 모듈 실패 시 WebBrowser 방식으로 폴백 - } - } - - // ✅ 방법 2: openAuthSessionAsync 사용 (Expo Go에서도 작동, 딥링크 리다이렉트 감지 가능) - const KAKAO_CLIENT_ID = '99baee411cc547822f138712b19b032c'; - const REDIRECT_URI = 'https://www.intelfits.com/api/auth/kakao/callback'; - const loginUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=profile_nickname,profile_image`; - - console.log('🔵 [카카오 로그인] WebBrowser 방식 사용'); - console.log('🔵 [카카오 로그인] URL:', loginUrl); - const result = await WebBrowser.openAuthSessionAsync( - loginUrl, - 'intelfit://auth/kakao' // 딥링크 스킴 - ); - - console.log('🔵 [카카오 로그인] 결과:', result); - - // openAuthSessionAsync가 성공적으로 딥링크를 받은 경우 - if (result.type === 'success' && result.url) { - console.log('🔵 [카카오 로그인] 딥링크 URL 받음:', result.url); - - // 기존 Linking 리스너가 처리하도록 이벤트 트리거 - // 또는 직접 파싱해서 처리 - const parsed = Linking.parse(result.url); - const code = parsed.queryParams?.code as string | undefined; - const accessToken = parsed.queryParams?.accessToken as string | undefined; - const refreshToken = parsed.queryParams?.refreshToken as string | undefined; - - // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 - if (accessToken) { - console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); - try { - setLoading(true); - - // 토큰 저장 - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, accessToken); - if (refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); - } - - // userId가 있으면 저장 - const userId = parsed.queryParams?.userId as string | undefined; - if (userId) { - await AsyncStorage.setItem('userId', userId); - } - - // membershipType 저장 (기본값 FREE) - const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; - await AsyncStorage.setItem('membershipType', membershipType); - - console.log('✅ [카카오 로그인] 토큰 저장 완료, 메인 화면으로 이동'); - // 메인 화면으로 이동 - navigation.replace('Main'); - } catch (error: any) { - console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); - Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - return; - } - - // 인증 코드가 있는 경우 (백엔드 API 호출 필요) - if (code) { - console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); - try { - setLoading(true); - - // 👉 서버 API 호출 - const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - } - ); - - console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); - const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); - - if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); - } - - // 토큰 저장 - if (data.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); - } - if (data.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); - } - if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); - } - if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); - } else { - await AsyncStorage.setItem('membershipType', 'FREE'); - } - - // 온보딩 여부 확인 - if (data.onboarded === false) { - navigation.replace('Onboarding'); - } else { - navigation.replace('Main'); - } - } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); - const errorMessage = error.message || '카카오 로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { - (window as any).alert(`로그인 실패\n${errorMessage}`); - } else { - Alert.alert('로그인 실패', errorMessage); - } - } finally { - setLoading(false); - } - } - } else if (result.type === 'cancel') { - console.log('⚠️ [카카오 로그인] 사용자가 취소함'); - } else { - console.log('⚠️ [카카오 로그인] 예상치 못한 결과:', result); - } - } catch (error) { - console.error('❌ [카카오 로그인] 에러:', error); - Alert.alert('오류', '카카오 로그인 페이지를 열 수 없습니다.'); - } finally { - setLoading(false); - } - }; - - return ( - - - - - INTEL FIT - - - - - handleChange('username', text)} - autoCapitalize="none" - placeholderTextColor={colors.textLight} - /> - {errors.username && ( - {errors.username} - )} - - - - handleChange('password', text)} - secureTextEntry - placeholderTextColor={colors.textLight} - /> - {errors.password && ( - {errors.password} - )} - - - - {loading ? ( - - ) : ( - 로그인 - )} - - - - - navigation.navigate('FindId')}> - 아이디 찾기 - - navigation.navigate('ResetPassword')}> - 비밀번호 재설정 - - navigation.navigate('Signup')}> - 회원가입 - - - - - 카카오로 계속하기 - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#252525', - }, - scrollContent: { - flexGrow: 1, - }, - loginContainer: { - flex: 1, - width: '100%', - paddingTop: 120, - paddingHorizontal: 20, - alignItems: 'center', - }, - logoContainer: { - alignItems: 'center', - marginBottom: 30, - }, - logo: { - fontSize: 40, - fontWeight: '800', - fontStyle: 'italic', - color: '#e3ff7c', - letterSpacing: 0, - }, - form: { - width: '100%', - maxWidth: 360, - marginBottom: 30, - gap: 20, - }, - inputGroup: { - gap: 8, - }, - input: { - width: '100%', - height: 60, - backgroundColor: '#434343', - borderWidth: 0, - borderRadius: 20, - paddingHorizontal: 20, - fontSize: 16, - fontWeight: '400', - color: colors.text, - }, - inputError: { - borderColor: colors.error, - }, - errorMessage: { - color: '#ff6b6b', - fontSize: 14, - marginLeft: 5, - }, - loginBtn: { - width: '100%', - height: 60, - backgroundColor: '#434343', - borderRadius: 100, - alignItems: 'center', - justifyContent: 'center', - opacity: 0.6, - }, - loginBtnDisabled: { - opacity: 0.4, - }, - loginBtnText: { - color: colors.text, - fontSize: 18, - fontWeight: '400', - }, - links: { - flexDirection: 'row', - justifyContent: 'center', - gap: 24, - width: '100%', - maxWidth: 360, - marginBottom: 30, - }, - linkText: { - color: colors.text, - fontSize: 16, - fontWeight: '400', - }, - kakaoBtn: { - width: '100%', - maxWidth: 360, - height: 50, - backgroundColor: '#ffe617', - borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', - }, - kakaoBtnText: { - color: '#47292b', - fontSize: 16, - fontWeight: '400', - }, -}); - -export default LoginScreen; - +import React, {useState, useEffect} from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import {colors} from '../../theme/colors'; +import {authAPI} from '../../services'; +import {ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY} from '../../services/apiConfig'; + +// @react-native-seoul/kakao-login (네이티브 빌드에서만 작동) +// Expo Go에서는 WebBrowser.openAuthSessionAsync 사용 +let KakaoLogin: any = null; +try { + KakaoLogin = require('@react-native-seoul/kakao-login'); +} catch (e) { + console.log('카카오 로그인 네이티브 모듈을 사용할 수 없습니다 (Expo Go 환경일 수 있음)'); +} + +const LoginScreen = ({navigation}: any) => { + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + const [errors, setErrors] = useState<{username?: string; password?: string}>({}); + const [loading, setLoading] = useState(false); + + const handleChange = (name: string, value: string) => { + setFormData(prev => ({ + ...prev, + [name]: value, + })); + if (errors[name as keyof typeof errors]) { + setErrors(prev => ({ + ...prev, + [name]: '', + })); + } + }; + + const validateForm = () => { + const newErrors: {username?: string; password?: string} = {}; + + if (!formData.username.trim()) { + newErrors.username = '아이디를 입력해주세요'; + } + + if (!formData.password.trim()) { + newErrors.password = '비밀번호를 입력해주세요'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) { + return; + } + + setLoading(true); + try { + const response = await authAPI.login(formData.username, formData.password); + + if (response.success && response.accessToken) { + navigation.replace('Main'); + } else { + const errorMessage = response.message || '로그인에 실패했습니다'; + if (typeof window !== 'undefined' && (window as any).alert) { + (window as any).alert(`로그인 실패\n${errorMessage}`); + } else { + Alert.alert('로그인 실패', errorMessage); + } + } + } catch (error: any) { + const errorMessage = error.message || '로그인에 실패했습니다'; + if (typeof window !== 'undefined' && (window as any).alert) { + (window as any).alert(`로그인 실패\n${errorMessage}`); + } else { + Alert.alert('로그인 실패', errorMessage); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + // 🔹 ② redirect_uri 가로채기 (가장 중요) + const subscription = Linking.addEventListener('url', async ({ url }) => { + console.log('🔗 [카카오 로그인] 딥링크 수신:', url); + + const parsed = Linking.parse(url); + const code = parsed.queryParams?.code as string | undefined; + const accessToken = parsed.queryParams?.accessToken as string | undefined; + const refreshToken = parsed.queryParams?.refreshToken as string | undefined; + + // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 + if (accessToken) { + console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); + try { + setLoading(true); + + // 토큰 저장 + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } + + // userId가 있으면 저장 + const userId = parsed.queryParams?.userId as string | undefined; + if (userId) { + await AsyncStorage.setItem('userId', userId); + } + + // membershipType 저장 (기본값 FREE) + const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; + await AsyncStorage.setItem('membershipType', membershipType); + + // 온보딩 여부 확인 + const isOnboarded = parsed.queryParams?.isOnboarded; + const onboarded = parsed.queryParams?.onboarded; + const shouldOnboard = isOnboarded === 'false' || onboarded === 'false'; + + console.log('✅ [카카오 로그인] 토큰 저장 완료'); + if (shouldOnboard) { + navigation.replace('KakaoOnboarding'); + } else { + navigation.replace('Main'); + } + } catch (error: any) { + console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); + Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + return; + } + + // 인증 코드가 있는 경우 (백엔드 API 호출 필요) + if (!code) { + console.log('⚠️ [카카오 로그인] 딥링크에 code 또는 accessToken이 없음'); + return; + } + + console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); + try { + setLoading(true); + + // 👉 여기서 서버 API 호출 + const res = await fetch( + 'https://www.intelfits.com/api/auth/kakao/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + } + ); + + console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); + const data = await res.json(); + console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); + + if (!res.ok) { + throw new Error(data.message || '카카오 로그인에 실패했습니다'); + } + + // 토큰 저장 + if (data.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); + } + if (data.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } + if (data.userId) { + await AsyncStorage.setItem('userId', String(data.userId)); + } + if (data.membershipType) { + await AsyncStorage.setItem('membershipType', data.membershipType); + } else { + await AsyncStorage.setItem('membershipType', 'FREE'); + } + + // 온보딩 여부 확인 (isOnboarded 또는 onboarded 값 확인) + const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; + if (isOnboarded === false) { + navigation.replace('KakaoOnboarding'); + } else { + navigation.replace('Main'); + } + } catch (error: any) { + console.error('카카오 로그인 처리 실패:', error); + const errorMessage = error.message || '카카오 로그인에 실패했습니다'; + if (Platform.OS === 'web') { + window.alert(`로그인 실패\n${errorMessage}`); + } else { + Alert.alert('로그인 실패', errorMessage); + } + } finally { + setLoading(false); + } + }); + + // 앱이 이미 열려있을 때 딥링크 처리 + Linking.getInitialURL().then(async (url) => { + if (url) { + const parsed = Linking.parse(url); + const code = parsed.queryParams?.code as string | undefined; + if (code) { + // 초기 URL에 코드가 있으면 처리 + try { + setLoading(true); + + const res = await fetch( + 'https://www.intelfits.com/api/auth/kakao/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + } + ); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.message || '카카오 로그인에 실패했습니다'); + } + + // 토큰 저장 + if (data.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); + } + if (data.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } + if (data.membershipType) { + await AsyncStorage.setItem('membershipType', data.membershipType); + } else { + await AsyncStorage.setItem('membershipType', 'FREE'); + } + + // 메인 화면으로 이동 + navigation.replace('Main'); + } catch (error: any) { + console.error('카카오 로그인 처리 실패:', error); + } finally { + setLoading(false); + } + } + } + }); + + return () => { + subscription.remove(); + }; + }, [navigation]); + + const handleKakaoLogin = async () => { + try { + setLoading(true); + console.log('🔵 [카카오 로그인] 시작'); + + // 웹 환경 체크 (Platform.OS가 'web'인 경우) + if (Platform.OS === 'web' && typeof window !== 'undefined' && window.open) { + const KAKAO_CLIENT_ID = '99baee411cc547822f138712b19b032c'; + const REDIRECT_URI = 'https://www.intelfits.com/api/auth/kakao/callback'; + const loginUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=profile_nickname,profile_image`; + // 웹에서는 새 창으로 열기 + window.open(loginUrl, '_blank'); + setLoading(false); + return; + } + + // ✅ 방법 1: @react-native-seoul/kakao-login 사용 (네이티브 빌드에서만 작동) + if (KakaoLogin && KakaoLogin.login) { + try { + console.log('🔵 [카카오 로그인] 네이티브 모듈 사용'); + const token = await KakaoLogin.login(); + console.log('✅ [카카오 로그인] 토큰 받음:', token); + + // 카카오 액세스 토큰을 백엔드로 전송하여 JWT 토큰 발급받기 + // 또는 카카오 프로필 정보를 가져와서 백엔드에 전송 + const profile = await KakaoLogin.getProfile(); + console.log('✅ [카카오 로그인] 프로필:', profile); + + // 백엔드 API 호출 (카카오 ID로 로그인/회원가입) + const res = await fetch( + 'https://www.intelfits.com/api/auth/kakao/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kakaoAccessToken: token.accessToken, + kakaoId: profile.id, + nickname: profile.nickname, + profileImage: profile.profileImageUrl + }), + } + ); + + const data = await res.json(); + console.log('🔵 [카카오 로그인] 백엔드 응답:', data); + + if (!res.ok) { + throw new Error(data.message || '카카오 로그인에 실패했습니다'); + } + + // 토큰 저장 + if (data.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); + } + if (data.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } + if (data.userId) { + await AsyncStorage.setItem('userId', String(data.userId)); + } + if (data.membershipType) { + await AsyncStorage.setItem('membershipType', data.membershipType); + } else { + await AsyncStorage.setItem('membershipType', 'FREE'); + } + + // 온보딩 여부 확인 (isOnboarded 또는 onboarded 값 확인) + const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; + if (isOnboarded === false) { + navigation.replace('KakaoOnboarding'); + } else { + navigation.replace('Main'); + } + return; + } catch (nativeError: any) { + console.log('⚠️ [카카오 로그인] 네이티브 모듈 실패, WebBrowser 방식으로 전환:', nativeError.message); + // 네이티브 모듈 실패 시 WebBrowser 방식으로 폴백 + } + } + + // ✅ 방법 2: openAuthSessionAsync 사용 (Expo Go에서도 작동, 딥링크 리다이렉트 감지 가능) + const KAKAO_CLIENT_ID = '99baee411cc547822f138712b19b032c'; + const REDIRECT_URI = 'https://www.intelfits.com/api/auth/kakao/callback'; + const loginUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=profile_nickname,profile_image`; + + console.log('🔵 [카카오 로그인] WebBrowser 방식 사용'); + console.log('🔵 [카카오 로그인] URL:', loginUrl); + const result = await WebBrowser.openAuthSessionAsync( + loginUrl, + 'intelfit://auth/kakao' // 딥링크 스킴 + ); + + console.log('🔵 [카카오 로그인] 결과:', result); + + // openAuthSessionAsync가 성공적으로 딥링크를 받은 경우 + if (result.type === 'success' && result.url) { + console.log('🔵 [카카오 로그인] 딥링크 URL 받음:', result.url); + + // 기존 Linking 리스너가 처리하도록 이벤트 트리거 + // 또는 직접 파싱해서 처리 + const parsed = Linking.parse(result.url); + const code = parsed.queryParams?.code as string | undefined; + const accessToken = parsed.queryParams?.accessToken as string | undefined; + const refreshToken = parsed.queryParams?.refreshToken as string | undefined; + + // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 + if (accessToken) { + console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); + try { + setLoading(true); + + // 토큰 저장 + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } + + // userId가 있으면 저장 + const userId = parsed.queryParams?.userId as string | undefined; + if (userId) { + await AsyncStorage.setItem('userId', userId); + } + + // membershipType 저장 (기본값 FREE) + const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; + await AsyncStorage.setItem('membershipType', membershipType); + + console.log('✅ [카카오 로그인] 토큰 저장 완료, 메인 화면으로 이동'); + // 메인 화면으로 이동 + navigation.replace('Main'); + } catch (error: any) { + console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); + Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + return; + } + + // 인증 코드가 있는 경우 (백엔드 API 호출 필요) + if (code) { + console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); + try { + setLoading(true); + + // 👉 서버 API 호출 + const res = await fetch( + 'https://www.intelfits.com/api/auth/kakao/login', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + } + ); + + console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); + const data = await res.json(); + console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); + + if (!res.ok) { + throw new Error(data.message || '카카오 로그인에 실패했습니다'); + } + + // 토큰 저장 + if (data.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); + } + if (data.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } + if (data.userId) { + await AsyncStorage.setItem('userId', String(data.userId)); + } + if (data.membershipType) { + await AsyncStorage.setItem('membershipType', data.membershipType); + } else { + await AsyncStorage.setItem('membershipType', 'FREE'); + } + + // 온보딩 여부 확인 + if (data.onboarded === false) { + navigation.replace('Onboarding'); + } else { + navigation.replace('Main'); + } + } catch (error: any) { + console.error('카카오 로그인 처리 실패:', error); + const errorMessage = error.message || '카카오 로그인에 실패했습니다'; + if (typeof window !== 'undefined' && (window as any).alert) { + (window as any).alert(`로그인 실패\n${errorMessage}`); + } else { + Alert.alert('로그인 실패', errorMessage); + } + } finally { + setLoading(false); + } + } + } else if (result.type === 'cancel') { + console.log('⚠️ [카카오 로그인] 사용자가 취소함'); + } else { + console.log('⚠️ [카카오 로그인] 예상치 못한 결과:', result); + } + } catch (error) { + console.error('❌ [카카오 로그인] 에러:', error); + Alert.alert('오류', '카카오 로그인 페이지를 열 수 없습니다.'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + INTEL FIT + + + + + handleChange('username', text)} + autoCapitalize="none" + placeholderTextColor={colors.textLight} + /> + {errors.username && ( + {errors.username} + )} + + + + handleChange('password', text)} + secureTextEntry + placeholderTextColor={colors.textLight} + /> + {errors.password && ( + {errors.password} + )} + + + + {loading ? ( + + ) : ( + 로그인 + )} + + + + + navigation.navigate('FindId')}> + 아이디 찾기 + + navigation.navigate('ResetPassword')}> + 비밀번호 재설정 + + navigation.navigate('Signup')}> + 회원가입 + + + + + 카카오로 계속하기 + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#252525', + }, + scrollContent: { + flexGrow: 1, + }, + loginContainer: { + flex: 1, + width: '100%', + paddingTop: 120, + paddingHorizontal: 20, + alignItems: 'center', + }, + logoContainer: { + alignItems: 'center', + marginBottom: 30, + }, + logo: { + fontSize: 40, + fontWeight: '800', + fontStyle: 'italic', + color: '#e3ff7c', + letterSpacing: 0, + }, + form: { + width: '100%', + maxWidth: 360, + marginBottom: 30, + gap: 20, + }, + inputGroup: { + gap: 8, + }, + input: { + width: '100%', + height: 60, + backgroundColor: '#434343', + borderWidth: 0, + borderRadius: 20, + paddingHorizontal: 20, + fontSize: 16, + fontWeight: '400', + color: colors.text, + }, + inputError: { + borderColor: colors.error, + }, + errorMessage: { + color: '#ff6b6b', + fontSize: 14, + marginLeft: 5, + }, + loginBtn: { + width: '100%', + height: 60, + backgroundColor: '#434343', + borderRadius: 100, + alignItems: 'center', + justifyContent: 'center', + opacity: 0.6, + }, + loginBtnDisabled: { + opacity: 0.4, + }, + loginBtnText: { + color: colors.text, + fontSize: 18, + fontWeight: '400', + }, + links: { + flexDirection: 'row', + justifyContent: 'center', + gap: 24, + width: '100%', + maxWidth: 360, + marginBottom: 30, + }, + linkText: { + color: colors.text, + fontSize: 16, + fontWeight: '400', + }, + kakaoBtn: { + width: '100%', + maxWidth: 360, + height: 50, + backgroundColor: '#ffe617', + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + kakaoBtnText: { + color: '#47292b', + fontSize: 16, + fontWeight: '400', + }, +}); + +export default LoginScreen; + diff --git a/src/screens/diet/MealRecommendHistoryScreen.tsx b/src/screens/diet/MealRecommendHistoryScreen.tsx index 00efb40..cf92f80 100644 --- a/src/screens/diet/MealRecommendHistoryScreen.tsx +++ b/src/screens/diet/MealRecommendHistoryScreen.tsx @@ -1,1089 +1,1125 @@ -// src/screens/MealRecommendHistoryScreen.tsx -import React, { useState } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Alert, - ActivityIndicator, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { useFocusEffect } from "@react-navigation/native"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { recommendedMealAPI } from "../../services"; - -type DayMealFood = { - name: string; - calories: number; - carbs: number; - protein: number; - fat: number; -}; - -type DayMealBlock = { - calories: number; - carbs: number; - protein: number; - fat: number; - meals: DayMealFood[]; -}; - -type DayMeal = { - totalCalories: number; - carbs: number; - protein: number; - fat: number; - breakfast?: DayMealBlock; - lunch?: DayMealBlock; - dinner?: DayMealBlock; -}; - -type SavedBundle = { - bundleId: string; - planName: string; - description?: string; - totalCalories: number; - mealCount: number; - createdAt: string; - planDate: string; - isServerMeal: boolean; - days?: DayMeal[]; -}; - -type LocalMeal = { - id: string; - bundleId: string; - planName: string; - description?: string; - date?: string; - meals?: DayMeal[]; - isServerMeal: false; -}; - -const LOCAL_KEYS = ["savedMeals", "savedMealPlans"] as const; - -const MealRecommendHistoryScreen = ({ navigation }: any) => { - const [bundles, setBundles] = useState([]); - const [selectedBundle, setSelectedBundle] = useState( - null - ); - const [selectedDay, setSelectedDay] = useState(0); - const [loading, setLoading] = useState(false); - const [isEditMode, setIsEditMode] = useState(false); - const [selectedBundleIds, setSelectedBundleIds] = useState([]); - - // ========== Normalizers ========== - const toNumber = (v: any, def = 0) => - typeof v === "number" && !Number.isNaN(v) ? v : Number(v ?? def) || def; - - const normalizeMealBlock = (raw: any): DayMealBlock | undefined => { - if (!raw) return undefined; - - const calories = toNumber(raw.totalCalories || raw.calories); - const carbs = toNumber(raw.totalCarbs || raw.carbs); - const protein = toNumber(raw.totalProtein || raw.protein); - const fat = toNumber(raw.totalFat || raw.fat); - - const rawItems = raw.foods || raw.meals || raw.items || []; - const meals: DayMealFood[] = Array.isArray(rawItems) - ? rawItems.map((it: any) => ({ - name: String(it?.foodName || it?.name || ""), - calories: toNumber(it?.calories), - carbs: toNumber(it?.carbs), - protein: toNumber(it?.protein), - fat: toNumber(it?.fat), - })) - : []; - - return { calories, carbs, protein, fat, meals }; - }; - const normalizeDay = (raw: any): DayMeal => { - const totalCalories = toNumber(raw?.totalCalories); - const carbs = toNumber(raw?.totalCarbs || raw?.carbs); - const protein = toNumber(raw?.totalProtein || raw?.protein); - const fat = toNumber(raw?.totalFat || raw?.fat); - - // meals 배열에서 찾기 - const mealsArray = raw?.meals || []; - - let breakfast = normalizeMealBlock( - mealsArray.find((m: any) => m.mealType === "BREAKFAST") || raw?.breakfast - ); - let lunch = normalizeMealBlock( - mealsArray.find((m: any) => m.mealType === "LUNCH") || raw?.lunch - ); - let dinner = normalizeMealBlock( - mealsArray.find((m: any) => m.mealType === "DINNER") || raw?.dinner - ); - - // ✅ SNACK 처리: BREAKFAST, LUNCH, DINNER가 없으면 SNACK을 순서대로 매핑 - if (!breakfast && !lunch && !dinner) { - const snacks = mealsArray.filter((m: any) => m.mealType === "SNACK"); - - if (snacks.length >= 1) breakfast = normalizeMealBlock(snacks[0]); - if (snacks.length >= 2) lunch = normalizeMealBlock(snacks[1]); - if (snacks.length >= 3) dinner = normalizeMealBlock(snacks[2]); - - console.log( - `⚠️ SNACK 매핑: ${ - snacks.length - }개 → 아침=${!!breakfast}, 점심=${!!lunch}, 저녁=${!!dinner}` - ); - } - - return { totalCalories, carbs, protein, fat, breakfast, lunch, dinner }; - }; - - // ========== 로컬 스토리지 ========== - const loadLocalMeals = async (): Promise => { - const results: LocalMeal[] = []; - for (const key of LOCAL_KEYS) { - try { - const json = await AsyncStorage.getItem(key); - if (!json) continue; - const parsed = JSON.parse(json); - if (!Array.isArray(parsed)) continue; - - parsed.forEach((item: any) => { - if (!item?.id) return; - - const localMeal: LocalMeal = { - id: String(item.id), - bundleId: `local_${item.id}`, - planName: item.planName || "로컬 식단", - description: item.description || "", - date: item.date || item.createdAt, - meals: Array.isArray(item.meals) - ? item.meals.map((m: any) => normalizeDay(m)) - : [], - isServerMeal: false, - }; - - results.push(localMeal); - }); - } catch (error) { - console.error(`로컬 스토리지 읽기 실패 (${key}):`, error); - } - } - return results; - }; - - // ========== 서버 API ========== - const loadServerBundles = async (): Promise => { - try { - const plans = await recommendedMealAPI.getSavedMealPlans(); - - console.log("📦 서버에서 받은 plans:", plans.length); - - // ✅ bundleId로 그룹화 - const bundleMap = new Map(); - - plans.forEach((plan) => { - if (!bundleMap.has(plan.bundleId)) { - // 첫 번째 plan으로 번들 생성 - bundleMap.set(plan.bundleId, { - bundleId: plan.bundleId, - planName: plan.planName, - description: "", // 나중에 채울 수 있음 - totalCalories: 0, // 평균으로 계산할 예정 - mealCount: 0, - createdAt: plan.createdAt, - planDate: plan.planDate, - isServerMeal: true, - }); - } - - // ✅ 번들 정보 업데이트 - const bundle = bundleMap.get(plan.bundleId)!; - bundle.mealCount++; - bundle.totalCalories += plan.totalCalories; - }); - - // ✅ 평균 칼로리 계산 - const bundles = Array.from(bundleMap.values()).map((bundle) => ({ - ...bundle, - totalCalories: Math.round( - bundle.totalCalories / (bundle.mealCount || 1) - ), - })); - - console.log("✅ 그룹화된 번들:", bundles.length); - return bundles; - } catch (error) { - console.error("서버 번들 불러오기 실패:", error); - return []; - } - }; - - const loadBundleDetail = async ( - bundleId: string - ): Promise => { - try { - console.log("🔍 번들 상세 조회:", bundleId); - - const days = await recommendedMealAPI.getBundleDetail(bundleId); - - console.log("📦 받은 days:", days.length); - - const normalized = days.map((day) => { - const result = normalizeDay(day); - console.log("📊 normalized day:", { - totalCalories: result.totalCalories, - hasBreakfast: !!result.breakfast, - hasLunch: !!result.lunch, - hasDinner: !!result.dinner, - }); - return result; - }); - - return normalized; - } catch (error) { - console.error("번들 상세 불러오기 실패:", error); - return null; - } - }; - - // ========== 통합 로드 ========== - const loadAllBundles = async () => { - try { - setLoading(true); - - const [localMeals, serverBundles] = await Promise.all([ - loadLocalMeals(), - loadServerBundles(), - ]); - - // 로컬 식단을 번들 형식으로 변환 - const localBundles: SavedBundle[] = localMeals.map((meal) => ({ - bundleId: meal.bundleId, - planName: meal.planName, - description: meal.description, - totalCalories: meal.meals?.[0]?.totalCalories || 0, - mealCount: meal.meals?.length || 0, - createdAt: meal.date || "", - planDate: meal.date || "", - isServerMeal: false, - days: meal.meals, - })); - - // 병합 및 정렬 - const allBundles = [...serverBundles, ...localBundles].sort((a, b) => { - const dateA = new Date(a.createdAt || 0).getTime(); - const dateB = new Date(b.createdAt || 0).getTime(); - return dateB - dateA; - }); - - console.log("📦 전체 번들:", allBundles.length); - console.log("- 서버:", serverBundles.length); - console.log("- 로컬:", localBundles.length); - - setBundles(allBundles); - } catch (error) { - console.error("번들 불러오기 실패:", error); - Alert.alert("오류", "식단을 불러오는데 실패했습니다."); - } finally { - setLoading(false); - } - }; - - useFocusEffect( - React.useCallback(() => { - loadAllBundles(); - setIsEditMode(false); - setSelectedBundleIds([]); - }, []) - ); - - // ========== 상세보기 ========== - const handleBundleClick = async (bundle: SavedBundle) => { - if (isEditMode) { - toggleBundleSelection(bundle.bundleId); - return; - } - - try { - setLoading(true); - - let days = bundle.days; - - // 서버 번들이고 상세 데이터가 없으면 API 호출 - if (bundle.isServerMeal && !days) { - days = await loadBundleDetail(bundle.bundleId); - if (!days || days.length === 0) { - Alert.alert("오류", "식단 상세 정보를 불러오지 못했습니다."); - return; - } - // 캐시 저장 - bundle.days = days; - } - - console.log("✅ 번들 상세 로드 완료:", days?.length, "일"); - - setSelectedBundle(bundle); - setSelectedDay(0); - } catch (error: any) { - console.error("번들 클릭 실패:", error); - Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleBack = () => { - setSelectedBundle(null); - setSelectedDay(0); - }; - - // ========== 삭제 ========== - const deleteLocalBundle = async (bundleId: string) => { - for (const key of LOCAL_KEYS) { - const json = await AsyncStorage.getItem(key); - if (!json) continue; - const arr = JSON.parse(json); - if (!Array.isArray(arr)) continue; - const filtered = arr.filter( - (m: any) => `local_${m.id}` !== bundleId && m.id !== bundleId - ); - await AsyncStorage.setItem(key, JSON.stringify(filtered)); - } - }; - - const handleDelete = async (bundle: SavedBundle) => { - Alert.alert( - "삭제", - `"${bundle.planName}" 식단을 삭제하시겠습니까?\n(${ - bundle.mealCount || bundle.days?.length || 0 - }일치)`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - if (bundle.isServerMeal) { - console.log("🗑️ 서버 번들 삭제:", bundle.bundleId); - await recommendedMealAPI.deleteBundle(bundle.bundleId); - } else { - console.log("🗑️ 로컬 번들 삭제:", bundle.bundleId); - await deleteLocalBundle(bundle.bundleId); - } - - setBundles((prev) => - prev.filter((b) => b.bundleId !== bundle.bundleId) - ); - - if (selectedBundle?.bundleId === bundle.bundleId) { - setSelectedBundle(null); - } - - Alert.alert("성공", "식단이 삭제되었습니다."); - } catch (error: any) { - console.error("삭제 실패:", error); - Alert.alert("오류", error.message || "삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ] - ); - }; - - // ========== 일괄 삭제 ========== - const toggleBundleSelection = (bundleId: string) => { - setSelectedBundleIds((prev) => - prev.includes(bundleId) - ? prev.filter((id) => id !== bundleId) - : [...prev, bundleId] - ); - }; - - const toggleSelectAll = () => { - if (selectedBundleIds.length === bundles.length) { - setSelectedBundleIds([]); - } else { - setSelectedBundleIds(bundles.map((b) => b.bundleId)); - } - }; - - const handleBulkDelete = async () => { - if (selectedBundleIds.length === 0) { - Alert.alert("알림", "삭제할 식단을 선택해주세요."); - return; - } - - Alert.alert( - "일괄 삭제", - `선택한 ${selectedBundleIds.length}개의 식단을 삭제하시겠습니까?`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - const serverBundleIds: string[] = []; - const localBundleIds: string[] = []; - - selectedBundleIds.forEach((id) => { - const bundle = bundles.find((b) => b.bundleId === id); - if (!bundle) return; - - if (bundle.isServerMeal) { - serverBundleIds.push(id); - } else { - localBundleIds.push(id); - } - }); - - // 서버 일괄 삭제 - if (serverBundleIds.length > 0) { - console.log("🗑️ 서버 번들 일괄 삭제:", serverBundleIds); - await recommendedMealAPI.deleteBulk(serverBundleIds); - } - - // 로컬 삭제 - for (const bundleId of localBundleIds) { - console.log("🗑️ 로컬 번들 삭제:", bundleId); - await deleteLocalBundle(bundleId); - } - - setBundles((prev) => - prev.filter((b) => !selectedBundleIds.includes(b.bundleId)) - ); - - setSelectedBundleIds([]); - setIsEditMode(false); - - Alert.alert( - "성공", - `${selectedBundleIds.length}개의 식단이 삭제되었습니다.` - ); - } catch (error: any) { - console.error("일괄 삭제 실패:", error); - Alert.alert("오류", error.message || "삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ] - ); - }; - - const currentDayMeal = selectedBundle?.days?.[selectedDay]; - - return ( - - - navigation.goBack()}> - - - - {selectedBundle ? "식단 상세보기" : "식단 추천 내역"} - - {!selectedBundle && bundles.length > 0 && ( - { - if (isEditMode) { - setIsEditMode(false); - setSelectedBundleIds([]); - } else { - setIsEditMode(true); - } - }} - > - {isEditMode ? "완료" : "편집"} - - )} - {!bundles.length && } - - - {loading && ( - - - 불러오는 중... - - )} - - - {!selectedBundle ? ( - bundles.length === 0 ? ( - - - 저장된 식단이 없습니다. - - 식단 추천을 받고 저장해보세요! - - navigation.navigate("MealRecommend")} - > - - 추천받으러 가기 → - - - - ) : ( - - {isEditMode && ( - - - - - 전체 선택 ({selectedBundleIds.length}/{bundles.length}) - - - - - 삭제 - - - )} - - {!isEditMode && ( - navigation.navigate("MealRecommend")} - > - - 새 식단 추천받기 - - - )} - - {bundles.map((bundle) => ( - handleBundleClick(bundle)} - activeOpacity={0.98} - > - {isEditMode && ( - - - - )} - - - - - {bundle.isServerMeal ? "☁️" : "📱"} - - - {bundle.planName} - - {new Date(bundle.createdAt).toLocaleDateString( - "ko-KR" - )} - - - - {!isEditMode && ( - { - e.stopPropagation(); - handleDelete(bundle); - }} - style={styles.deleteBtn} - > - - - )} - - - - {bundle.description && ( - - {bundle.description} - - )} - - - - - 📅 {bundle.mealCount || bundle.days?.length || 0}일 - 식단 - - - - - {bundle.totalCalories} kcal/일 - - - {bundle.isServerMeal && ( - - ☁️ 서버 - - )} - - - - {!isEditMode && ( - - 자세히 보기 → - - )} - - ))} - - ) - ) : ( - - - ← 목록으로 - - - - - - - {selectedBundle.planName} - - - {new Date(selectedBundle.createdAt).toLocaleDateString( - "ko-KR" - )} - - - {selectedBundle.isServerMeal && ( - - ☁️ 서버 - - )} - - - {selectedBundle.description && ( - - {selectedBundle.description} - - )} - - - {selectedBundle.days && selectedBundle.days.length > 0 && ( - <> - - {selectedBundle.days.map((_: any, index: number) => ( - setSelectedDay(index)} - > - - {index + 1}일차 - - - ))} - - - {currentDayMeal && ( - <> - - - {currentDayMeal.totalCalories} kcal - - - - 탄수화물 - - {currentDayMeal.carbs}g - - - - 단백질 - - {currentDayMeal.protein}g - - - - 지방 - - {currentDayMeal.fat}g - - - - - - {currentDayMeal.breakfast && ( - - - 🌅 아침 - - {currentDayMeal.breakfast.calories} kcal - - - - {currentDayMeal.breakfast.meals.map((item, index) => ( - - - {item.name} - - - - {item.calories}kcal - - - 탄{item.carbs}g · 단{item.protein}g · 지 - {item.fat}g - - - - ))} - - - )} - - {currentDayMeal.lunch && ( - - - ☀️ 점심 - - {currentDayMeal.lunch.calories} kcal - - - - {currentDayMeal.lunch.meals.map((item, index) => ( - - - {item.name} - - - - {item.calories}kcal - - - 탄{item.carbs}g · 단{item.protein}g · 지 - {item.fat}g - - - - ))} - - - )} - - {currentDayMeal.dinner && ( - - - 🌙 저녁 - - {currentDayMeal.dinner.calories} kcal - - - - {currentDayMeal.dinner.meals.map((item, index) => ( - - - {item.name} - - - - {item.calories}kcal - - - 탄{item.carbs}g · 단{item.protein}g · 지 - {item.fat}g - - - - ))} - - - )} - - )} - - )} - - )} - - - ); -}; - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: "#1a1a1a" }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 20, - borderBottomWidth: 1, - borderBottomColor: "#333333", - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - flex: 1, - textAlign: "center", - }, - editBtn: { fontSize: 16, fontWeight: "600", color: "#e3ff7c" }, - loadingOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0,0,0,0.7)", - justifyContent: "center", - alignItems: "center", - zIndex: 999, - }, - loadingText: { - color: "#ffffff", - fontSize: 16, - marginTop: 12, - fontWeight: "600", - }, - content: { flex: 1, padding: 20 }, - emptyState: { - alignItems: "center", - paddingVertical: 60, - paddingHorizontal: 20, - }, - emptyText: { - fontSize: 16, - color: "#999999", - textAlign: "center", - marginTop: 20, - marginBottom: 10, - }, - emptySubtitle: { - fontSize: 14, - color: "#666666", - textAlign: "center", - marginBottom: 24, - }, - goToRecommendBtn: { - backgroundColor: "#e3ff7c", - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 12, - marginTop: 16, - }, - goToRecommendBtnText: { fontSize: 15, fontWeight: "600", color: "#111111" }, - list: { gap: 16 }, - // ✅ 편집 모드 툴바 - editToolbar: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - backgroundColor: "#222222", - padding: 16, - borderRadius: 12, - marginBottom: 12, - }, - selectAllBtn: { flexDirection: "row", alignItems: "center", gap: 8 }, - selectAllText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, - bulkDeleteBtn: { - flexDirection: "row", - alignItems: "center", - gap: 6, - backgroundColor: "#ef4444", - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 8, - }, - bulkDeleteBtnDisabled: { backgroundColor: "#666666", opacity: 0.5 }, - bulkDeleteText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, - newRecommendBtn: { - backgroundColor: "#e3ff7c", - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignSelf: "flex-start", - marginBottom: 8, - }, - newRecommendBtnText: { fontSize: 14, fontWeight: "600", color: "#111111" }, - card: { - backgroundColor: "#222222", - borderRadius: 12, - padding: 16, - borderWidth: 2, - borderColor: "transparent", - }, - cardSelected: { borderColor: "#e3ff7c", backgroundColor: "#2a2a1a" }, - checkbox: { position: "absolute", top: 12, left: 12, zIndex: 10 }, - cardHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 12, - }, - dateContainer: { - flexDirection: "row", - alignItems: "center", - gap: 12, - flex: 1, - }, - dateIcon: { fontSize: 24 }, - planName: { - fontSize: 16, - fontWeight: "700", - color: "#ffffff", - marginBottom: 2, - }, - date: { fontSize: 13, fontWeight: "500", color: "#999999" }, - deleteBtn: { padding: 4 }, - cardBody: { marginBottom: 12, gap: 8 }, - description: { fontSize: 14, color: "#cccccc", lineHeight: 20 }, - summary: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - badge: { - paddingVertical: 4, - paddingHorizontal: 10, - borderRadius: 12, - backgroundColor: "#4a90e2", - }, - caloriesBadge: { backgroundColor: "#e3ff7c" }, - serverBadge: { backgroundColor: "#8b5cf6" }, - badgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, - caloriesBadgeText: { fontSize: 12, fontWeight: "500", color: "#111111" }, - serverBadgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, - cardFooter: { alignItems: "flex-end" }, - viewDetail: { fontSize: 14, color: "#e3ff7c", fontWeight: "500" }, - detail: { gap: 20 }, - backBtn: { - backgroundColor: "#2a2a2a", - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignSelf: "flex-start", - }, - backBtnText: { fontSize: 14, fontWeight: "500", color: "#ffffff" }, - detailInfo: { backgroundColor: "#222222", padding: 16, borderRadius: 12 }, - detailHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "flex-start", - marginBottom: 8, - }, - detailPlanName: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - marginBottom: 4, - }, - detailDate: { fontSize: 14, fontWeight: "500", color: "#999999" }, - detailDescription: { - fontSize: 14, - color: "#cccccc", - lineHeight: 20, - marginTop: 8, - }, - dayTabsContainer: { marginVertical: 8 }, - dayTabs: { gap: 8, paddingBottom: 8 }, - dayTab: { - paddingVertical: 8, - paddingHorizontal: 16, - backgroundColor: "#222222", - borderRadius: 20, - }, - dayTabActive: { backgroundColor: "#e3ff7c" }, - dayTabText: { fontSize: 13, fontWeight: "500", color: "#999999" }, - dayTabTextActive: { color: "#111111", fontWeight: "600" }, - nutritionCard: { - backgroundColor: "#667eea", - borderRadius: 16, - padding: 20, - alignItems: "center", - minHeight: 140, - justifyContent: "center", - }, - caloriesTotal: { - fontSize: 32, - fontWeight: "700", - color: "#ffffff", - marginBottom: 16, - }, - nutritionGrid: { - flexDirection: "row", - justifyContent: "space-around", - width: "100%", - gap: 16, - }, - nutritionItem: { alignItems: "center", gap: 4 }, - nutritionLabel: { fontSize: 12, color: "#ffffff", opacity: 0.8 }, - nutritionValue: { fontSize: 18, fontWeight: "600", color: "#ffffff" }, - mealSection: { backgroundColor: "#222222", borderRadius: 12, padding: 16 }, - mealSectionHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 12, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: "#333333", - }, - mealSectionTitle: { fontSize: 16, fontWeight: "600", color: "#ffffff" }, - mealSectionCalories: { fontSize: 14, fontWeight: "600", color: "#e3ff7c" }, - mealItems: { gap: 12 }, - mealItemDetail: { - backgroundColor: "#2a2a2a", - padding: 12, - borderRadius: 8, - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - mealItemName: { fontSize: 14, fontWeight: "500", color: "#ffffff", flex: 1 }, - mealItemNutrition: { alignItems: "flex-end", gap: 4 }, - mealItemCalories: { fontSize: 13, fontWeight: "600", color: "#e3ff7c" }, - mealItemMacros: { fontSize: 11, color: "#999999" }, -}); - -export default MealRecommendHistoryScreen; +// src/screens/MealRecommendHistoryScreen.tsx +import React, { useState } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { recommendedMealAPI } from "../../services"; +import { authAPI } from "../../services"; + +type DayMealFood = { + name: string; + calories: number; + carbs: number; + protein: number; + fat: number; +}; + +type DayMealBlock = { + calories: number; + carbs: number; + protein: number; + fat: number; + meals: DayMealFood[]; +}; + +type DayMeal = { + totalCalories: number; + carbs: number; + protein: number; + fat: number; + breakfast?: DayMealBlock; + lunch?: DayMealBlock; + dinner?: DayMealBlock; +}; + +type SavedBundle = { + bundleId: string; + planName: string; + description?: string; + totalCalories: number; + mealCount: number; + createdAt: string; + planDate: string; + isServerMeal: boolean; + days?: DayMeal[]; +}; + +type LocalMeal = { + id: string; + bundleId: string; + planName: string; + description?: string; + date?: string; + meals?: DayMeal[]; + isServerMeal: false; +}; + +const LOCAL_KEYS = ["savedMeals", "savedMealPlans"] as const; + +const MealRecommendHistoryScreen = ({ navigation }: any) => { + const [bundles, setBundles] = useState([]); + const [selectedBundle, setSelectedBundle] = useState( + null + ); + const [selectedDay, setSelectedDay] = useState(0); + const [loading, setLoading] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [selectedBundleIds, setSelectedBundleIds] = useState([]); + + // ========== Normalizers ========== + const toNumber = (v: any, def = 0) => + typeof v === "number" && !Number.isNaN(v) ? v : Number(v ?? def) || def; + const handleNewRecommendPress = async () => { + try { + setLoading(true); + + // 사용자 프로필 조회 + const profile = await authAPI.getProfile(); + + console.log("✅ 멤버십 타입:", profile.membershipType); + + // 멤버십 타입에 따라 다른 화면으로 이동 + if (profile.membershipType === "FREE") { + console.log("➡️ 무료 회원 - TempRoutineRecommendScreen으로 이동"); + navigation.navigate("TempMealRecommend"); + } else { + console.log("➡️ 프리미엄 회원 - RoutineRecommendNew로 이동"); + navigation.navigate("MealRecommend"); + } + } catch (error: any) { + console.error("❌ 프로필 조회 실패:", error); + Alert.alert("오류", "사용자 정보를 불러오는데 실패했습니다.", [ + { + text: "확인", + onPress: () => { + // 실패 시 기본적으로 무료 버전으로 이동 + navigation.navigate("TempMealRecommendScreen"); + }, + }, + ]); + } finally { + setLoading(false); + } + }; + const normalizeMealBlock = (raw: any): DayMealBlock | undefined => { + if (!raw) return undefined; + + const calories = toNumber(raw.totalCalories || raw.calories); + const carbs = toNumber(raw.totalCarbs || raw.carbs); + const protein = toNumber(raw.totalProtein || raw.protein); + const fat = toNumber(raw.totalFat || raw.fat); + + const rawItems = raw.foods || raw.meals || raw.items || []; + const meals: DayMealFood[] = Array.isArray(rawItems) + ? rawItems.map((it: any) => ({ + name: String(it?.foodName || it?.name || ""), + calories: toNumber(it?.calories), + carbs: toNumber(it?.carbs), + protein: toNumber(it?.protein), + fat: toNumber(it?.fat), + })) + : []; + + return { calories, carbs, protein, fat, meals }; + }; + const normalizeDay = (raw: any): DayMeal => { + const totalCalories = toNumber(raw?.totalCalories); + const carbs = toNumber(raw?.totalCarbs || raw?.carbs); + const protein = toNumber(raw?.totalProtein || raw?.protein); + const fat = toNumber(raw?.totalFat || raw?.fat); + + // meals 배열에서 찾기 + const mealsArray = raw?.meals || []; + + let breakfast = normalizeMealBlock( + mealsArray.find((m: any) => m.mealType === "BREAKFAST") || raw?.breakfast + ); + let lunch = normalizeMealBlock( + mealsArray.find((m: any) => m.mealType === "LUNCH") || raw?.lunch + ); + let dinner = normalizeMealBlock( + mealsArray.find((m: any) => m.mealType === "DINNER") || raw?.dinner + ); + + // ✅ SNACK 처리: BREAKFAST, LUNCH, DINNER가 없으면 SNACK을 순서대로 매핑 + if (!breakfast && !lunch && !dinner) { + const snacks = mealsArray.filter((m: any) => m.mealType === "SNACK"); + + if (snacks.length >= 1) breakfast = normalizeMealBlock(snacks[0]); + if (snacks.length >= 2) lunch = normalizeMealBlock(snacks[1]); + if (snacks.length >= 3) dinner = normalizeMealBlock(snacks[2]); + + console.log( + `⚠️ SNACK 매핑: ${ + snacks.length + }개 → 아침=${!!breakfast}, 점심=${!!lunch}, 저녁=${!!dinner}` + ); + } + + return { totalCalories, carbs, protein, fat, breakfast, lunch, dinner }; + }; + + // ========== 로컬 스토리지 ========== + const loadLocalMeals = async (): Promise => { + const results: LocalMeal[] = []; + for (const key of LOCAL_KEYS) { + try { + const json = await AsyncStorage.getItem(key); + if (!json) continue; + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) continue; + + parsed.forEach((item: any) => { + if (!item?.id) return; + + const localMeal: LocalMeal = { + id: String(item.id), + bundleId: `local_${item.id}`, + planName: item.planName || "로컬 식단", + description: item.description || "", + date: item.date || item.createdAt, + meals: Array.isArray(item.meals) + ? item.meals.map((m: any) => normalizeDay(m)) + : [], + isServerMeal: false, + }; + + results.push(localMeal); + }); + } catch (error) { + console.error(`로컬 스토리지 읽기 실패 (${key}):`, error); + } + } + return results; + }; + + // ========== 서버 API ========== + const loadServerBundles = async (): Promise => { + try { + const plans = await recommendedMealAPI.getSavedMealPlans(); + + console.log("📦 서버에서 받은 plans:", plans.length); + + // ✅ bundleId로 그룹화 + const bundleMap = new Map(); + + plans.forEach((plan) => { + if (!bundleMap.has(plan.bundleId)) { + // 첫 번째 plan으로 번들 생성 + bundleMap.set(plan.bundleId, { + bundleId: plan.bundleId, + planName: plan.planName, + description: "", // 나중에 채울 수 있음 + totalCalories: 0, // 평균으로 계산할 예정 + mealCount: 0, + createdAt: plan.createdAt, + planDate: plan.planDate, + isServerMeal: true, + }); + } + + // ✅ 번들 정보 업데이트 + const bundle = bundleMap.get(plan.bundleId)!; + bundle.mealCount++; + bundle.totalCalories += plan.totalCalories; + }); + + // ✅ 평균 칼로리 계산 + const bundles = Array.from(bundleMap.values()).map((bundle) => ({ + ...bundle, + totalCalories: Math.round( + bundle.totalCalories / (bundle.mealCount || 1) + ), + })); + + console.log("✅ 그룹화된 번들:", bundles.length); + return bundles; + } catch (error) { + console.error("서버 번들 불러오기 실패:", error); + return []; + } + }; + + const loadBundleDetail = async ( + bundleId: string + ): Promise => { + try { + console.log("🔍 번들 상세 조회:", bundleId); + + const days = await recommendedMealAPI.getBundleDetail(bundleId); + + console.log("📦 받은 days:", days.length); + + const normalized = days.map((day) => { + const result = normalizeDay(day); + console.log("📊 normalized day:", { + totalCalories: result.totalCalories, + hasBreakfast: !!result.breakfast, + hasLunch: !!result.lunch, + hasDinner: !!result.dinner, + }); + return result; + }); + + return normalized; + } catch (error) { + console.error("번들 상세 불러오기 실패:", error); + return null; + } + }; + + // ========== 통합 로드 ========== + const loadAllBundles = async () => { + try { + setLoading(true); + + const [localMeals, serverBundles] = await Promise.all([ + loadLocalMeals(), + loadServerBundles(), + ]); + + // 로컬 식단을 번들 형식으로 변환 + const localBundles: SavedBundle[] = localMeals.map((meal) => ({ + bundleId: meal.bundleId, + planName: meal.planName, + description: meal.description, + totalCalories: meal.meals?.[0]?.totalCalories || 0, + mealCount: meal.meals?.length || 0, + createdAt: meal.date || "", + planDate: meal.date || "", + isServerMeal: false, + days: meal.meals, + })); + + // 병합 및 정렬 + const allBundles = [...serverBundles, ...localBundles].sort((a, b) => { + const dateA = new Date(a.createdAt || 0).getTime(); + const dateB = new Date(b.createdAt || 0).getTime(); + return dateB - dateA; + }); + + console.log("📦 전체 번들:", allBundles.length); + console.log("- 서버:", serverBundles.length); + console.log("- 로컬:", localBundles.length); + + setBundles(allBundles); + } catch (error) { + console.error("번들 불러오기 실패:", error); + Alert.alert("오류", "식단을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + useFocusEffect( + React.useCallback(() => { + loadAllBundles(); + setIsEditMode(false); + setSelectedBundleIds([]); + }, []) + ); + + // ========== 상세보기 ========== + const handleBundleClick = async (bundle: SavedBundle) => { + if (isEditMode) { + toggleBundleSelection(bundle.bundleId); + return; + } + + try { + setLoading(true); + + let days = bundle.days; + + // 서버 번들이고 상세 데이터가 없으면 API 호출 + if (bundle.isServerMeal && !days) { + days = await loadBundleDetail(bundle.bundleId); + if (!days || days.length === 0) { + Alert.alert("오류", "식단 상세 정보를 불러오지 못했습니다."); + return; + } + // 캐시 저장 + bundle.days = days; + } + + console.log("✅ 번들 상세 로드 완료:", days?.length, "일"); + + setSelectedBundle(bundle); + setSelectedDay(0); + } catch (error: any) { + console.error("번들 클릭 실패:", error); + Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleBack = () => { + setSelectedBundle(null); + setSelectedDay(0); + }; + + // ========== 삭제 ========== + const deleteLocalBundle = async (bundleId: string) => { + for (const key of LOCAL_KEYS) { + const json = await AsyncStorage.getItem(key); + if (!json) continue; + const arr = JSON.parse(json); + if (!Array.isArray(arr)) continue; + const filtered = arr.filter( + (m: any) => `local_${m.id}` !== bundleId && m.id !== bundleId + ); + await AsyncStorage.setItem(key, JSON.stringify(filtered)); + } + }; + + const handleDelete = async (bundle: SavedBundle) => { + Alert.alert( + "삭제", + `"${bundle.planName}" 식단을 삭제하시겠습니까?\n(${ + bundle.mealCount || bundle.days?.length || 0 + }일치)`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + if (bundle.isServerMeal) { + console.log("🗑️ 서버 번들 삭제:", bundle.bundleId); + await recommendedMealAPI.deleteBundle(bundle.bundleId); + } else { + console.log("🗑️ 로컬 번들 삭제:", bundle.bundleId); + await deleteLocalBundle(bundle.bundleId); + } + + setBundles((prev) => + prev.filter((b) => b.bundleId !== bundle.bundleId) + ); + + if (selectedBundle?.bundleId === bundle.bundleId) { + setSelectedBundle(null); + } + + Alert.alert("성공", "식단이 삭제되었습니다."); + } catch (error: any) { + console.error("삭제 실패:", error); + Alert.alert("오류", error.message || "삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + // ========== 일괄 삭제 ========== + const toggleBundleSelection = (bundleId: string) => { + setSelectedBundleIds((prev) => + prev.includes(bundleId) + ? prev.filter((id) => id !== bundleId) + : [...prev, bundleId] + ); + }; + + const toggleSelectAll = () => { + if (selectedBundleIds.length === bundles.length) { + setSelectedBundleIds([]); + } else { + setSelectedBundleIds(bundles.map((b) => b.bundleId)); + } + }; + + const handleBulkDelete = async () => { + if (selectedBundleIds.length === 0) { + Alert.alert("알림", "삭제할 식단을 선택해주세요."); + return; + } + + Alert.alert( + "일괄 삭제", + `선택한 ${selectedBundleIds.length}개의 식단을 삭제하시겠습니까?`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + const serverBundleIds: string[] = []; + const localBundleIds: string[] = []; + + selectedBundleIds.forEach((id) => { + const bundle = bundles.find((b) => b.bundleId === id); + if (!bundle) return; + + if (bundle.isServerMeal) { + serverBundleIds.push(id); + } else { + localBundleIds.push(id); + } + }); + + // 서버 일괄 삭제 + if (serverBundleIds.length > 0) { + console.log("🗑️ 서버 번들 일괄 삭제:", serverBundleIds); + await recommendedMealAPI.deleteBulk(serverBundleIds); + } + + // 로컬 삭제 + for (const bundleId of localBundleIds) { + console.log("🗑️ 로컬 번들 삭제:", bundleId); + await deleteLocalBundle(bundleId); + } + + setBundles((prev) => + prev.filter((b) => !selectedBundleIds.includes(b.bundleId)) + ); + + setSelectedBundleIds([]); + setIsEditMode(false); + + Alert.alert( + "성공", + `${selectedBundleIds.length}개의 식단이 삭제되었습니다.` + ); + } catch (error: any) { + console.error("일괄 삭제 실패:", error); + Alert.alert("오류", error.message || "삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const currentDayMeal = selectedBundle?.days?.[selectedDay]; + + return ( + + + navigation.goBack()}> + + + + {selectedBundle ? "식단 상세보기" : "식단 추천 내역"} + + {!selectedBundle && bundles.length > 0 && ( + { + if (isEditMode) { + setIsEditMode(false); + setSelectedBundleIds([]); + } else { + setIsEditMode(true); + } + }} + > + {isEditMode ? "완료" : "편집"} + + )} + {!bundles.length && } + + + {loading && ( + + + 불러오는 중... + + )} + + + {!selectedBundle ? ( + bundles.length === 0 ? ( + + + 저장된 식단이 없습니다. + + 식단 추천을 받고 저장해보세요! + + navigation.navigate("MealRecommend")} + > + + 추천받으러 가기 → + + + + ) : ( + + {isEditMode && ( + + + + + 전체 선택 ({selectedBundleIds.length}/{bundles.length}) + + + + + 삭제 + + + )} + + {!isEditMode && ( + + {loading ? ( + + ) : ( + + 새 식단 추천받기 + + )} + + )} + {bundles.map((bundle) => ( + handleBundleClick(bundle)} + activeOpacity={0.98} + > + {isEditMode && ( + + + + )} + + + + + {bundle.isServerMeal ? "☁️" : "📱"} + + + {bundle.planName} + + {new Date(bundle.createdAt).toLocaleDateString( + "ko-KR" + )} + + + + {!isEditMode && ( + { + e.stopPropagation(); + handleDelete(bundle); + }} + style={styles.deleteBtn} + > + + + )} + + + + {bundle.description && ( + + {bundle.description} + + )} + + + + + 📅 {bundle.mealCount || bundle.days?.length || 0}일 + 식단 + + + + + {bundle.totalCalories} kcal/일 + + + {bundle.isServerMeal && ( + + ☁️ 서버 + + )} + + + + {!isEditMode && ( + + 자세히 보기 → + + )} + + ))} + + ) + ) : ( + + + ← 목록으로 + + + + + + + {selectedBundle.planName} + + + {new Date(selectedBundle.createdAt).toLocaleDateString( + "ko-KR" + )} + + + {selectedBundle.isServerMeal && ( + + ☁️ 서버 + + )} + + + {selectedBundle.description && ( + + {selectedBundle.description} + + )} + + + {selectedBundle.days && selectedBundle.days.length > 0 && ( + <> + + {selectedBundle.days.map((_: any, index: number) => ( + setSelectedDay(index)} + > + + {index + 1}일차 + + + ))} + + + {currentDayMeal && ( + <> + + + {currentDayMeal.totalCalories} kcal + + + + 탄수화물 + + {currentDayMeal.carbs}g + + + + 단백질 + + {currentDayMeal.protein}g + + + + 지방 + + {currentDayMeal.fat}g + + + + + + {currentDayMeal.breakfast && ( + + + 🌅 아침 + + {currentDayMeal.breakfast.calories} kcal + + + + {currentDayMeal.breakfast.meals.map((item, index) => ( + + + {item.name} + + + + {item.calories}kcal + + + 탄{item.carbs}g · 단{item.protein}g · 지 + {item.fat}g + + + + ))} + + + )} + + {currentDayMeal.lunch && ( + + + ☀️ 점심 + + {currentDayMeal.lunch.calories} kcal + + + + {currentDayMeal.lunch.meals.map((item, index) => ( + + + {item.name} + + + + {item.calories}kcal + + + 탄{item.carbs}g · 단{item.protein}g · 지 + {item.fat}g + + + + ))} + + + )} + + {currentDayMeal.dinner && ( + + + 🌙 저녁 + + {currentDayMeal.dinner.calories} kcal + + + + {currentDayMeal.dinner.meals.map((item, index) => ( + + + {item.name} + + + + {item.calories}kcal + + + 탄{item.carbs}g · 단{item.protein}g · 지 + {item.fat}g + + + + ))} + + + )} + + )} + + )} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#1a1a1a" }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 20, + borderBottomWidth: 1, + borderBottomColor: "#333333", + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + flex: 1, + textAlign: "center", + }, + editBtn: { fontSize: 16, fontWeight: "600", color: "#e3ff7c" }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.7)", + justifyContent: "center", + alignItems: "center", + zIndex: 999, + }, + loadingText: { + color: "#ffffff", + fontSize: 16, + marginTop: 12, + fontWeight: "600", + }, + content: { flex: 1, padding: 20 }, + emptyState: { + alignItems: "center", + paddingVertical: 60, + paddingHorizontal: 20, + }, + emptyText: { + fontSize: 16, + color: "#999999", + textAlign: "center", + marginTop: 20, + marginBottom: 10, + }, + emptySubtitle: { + fontSize: 14, + color: "#666666", + textAlign: "center", + marginBottom: 24, + }, + goToRecommendBtn: { + backgroundColor: "#e3ff7c", + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 12, + marginTop: 16, + }, + goToRecommendBtnText: { fontSize: 15, fontWeight: "600", color: "#111111" }, + list: { gap: 16 }, + // ✅ 편집 모드 툴바 + editToolbar: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#222222", + padding: 16, + borderRadius: 12, + marginBottom: 12, + }, + selectAllBtn: { flexDirection: "row", alignItems: "center", gap: 8 }, + selectAllText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, + bulkDeleteBtn: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: "#ef4444", + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + bulkDeleteBtnDisabled: { backgroundColor: "#666666", opacity: 0.5 }, + bulkDeleteText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, + newRecommendBtn: { + backgroundColor: "#e3ff7c", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignSelf: "flex-start", + marginBottom: 8, + }, + newRecommendBtnText: { fontSize: 14, fontWeight: "600", color: "#111111" }, + card: { + backgroundColor: "#222222", + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: "transparent", + }, + cardSelected: { borderColor: "#e3ff7c", backgroundColor: "#2a2a1a" }, + checkbox: { position: "absolute", top: 12, left: 12, zIndex: 10 }, + cardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }, + dateContainer: { + flexDirection: "row", + alignItems: "center", + gap: 12, + flex: 1, + }, + dateIcon: { fontSize: 24 }, + planName: { + fontSize: 16, + fontWeight: "700", + color: "#ffffff", + marginBottom: 2, + }, + date: { fontSize: 13, fontWeight: "500", color: "#999999" }, + deleteBtn: { padding: 4 }, + cardBody: { marginBottom: 12, gap: 8 }, + description: { fontSize: 14, color: "#cccccc", lineHeight: 20 }, + summary: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + badge: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 12, + backgroundColor: "#4a90e2", + }, + caloriesBadge: { backgroundColor: "#e3ff7c" }, + serverBadge: { backgroundColor: "#8b5cf6" }, + badgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, + caloriesBadgeText: { fontSize: 12, fontWeight: "500", color: "#111111" }, + serverBadgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, + cardFooter: { alignItems: "flex-end" }, + viewDetail: { fontSize: 14, color: "#e3ff7c", fontWeight: "500" }, + detail: { gap: 20 }, + backBtn: { + backgroundColor: "#2a2a2a", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignSelf: "flex-start", + }, + backBtnText: { fontSize: 14, fontWeight: "500", color: "#ffffff" }, + detailInfo: { backgroundColor: "#222222", padding: 16, borderRadius: 12 }, + detailHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: 8, + }, + detailPlanName: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + }, + detailDate: { fontSize: 14, fontWeight: "500", color: "#999999" }, + detailDescription: { + fontSize: 14, + color: "#cccccc", + lineHeight: 20, + marginTop: 8, + }, + dayTabsContainer: { marginVertical: 8 }, + dayTabs: { gap: 8, paddingBottom: 8 }, + dayTab: { + paddingVertical: 8, + paddingHorizontal: 16, + backgroundColor: "#222222", + borderRadius: 20, + }, + dayTabActive: { backgroundColor: "#e3ff7c" }, + dayTabText: { fontSize: 13, fontWeight: "500", color: "#999999" }, + dayTabTextActive: { color: "#111111", fontWeight: "600" }, + nutritionCard: { + backgroundColor: "#667eea", + borderRadius: 16, + padding: 20, + alignItems: "center", + minHeight: 140, + justifyContent: "center", + }, + caloriesTotal: { + fontSize: 32, + fontWeight: "700", + color: "#ffffff", + marginBottom: 16, + }, + nutritionGrid: { + flexDirection: "row", + justifyContent: "space-around", + width: "100%", + gap: 16, + }, + nutritionItem: { alignItems: "center", gap: 4 }, + nutritionLabel: { fontSize: 12, color: "#ffffff", opacity: 0.8 }, + nutritionValue: { fontSize: 18, fontWeight: "600", color: "#ffffff" }, + mealSection: { backgroundColor: "#222222", borderRadius: 12, padding: 16 }, + mealSectionHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: "#333333", + }, + mealSectionTitle: { fontSize: 16, fontWeight: "600", color: "#ffffff" }, + mealSectionCalories: { fontSize: 14, fontWeight: "600", color: "#e3ff7c" }, + mealItems: { gap: 12 }, + mealItemDetail: { + backgroundColor: "#2a2a2a", + padding: 12, + borderRadius: 8, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + mealItemName: { fontSize: 14, fontWeight: "500", color: "#ffffff", flex: 1 }, + mealItemNutrition: { alignItems: "flex-end", gap: 4 }, + mealItemCalories: { fontSize: 13, fontWeight: "600", color: "#e3ff7c" }, + mealItemMacros: { fontSize: 11, color: "#999999" }, +}); + +export default MealRecommendHistoryScreen; diff --git a/src/screens/diet/MealRecommendScreen.tsx b/src/screens/diet/MealRecommendScreen.tsx index 64d98d5..edd96b0 100644 --- a/src/screens/diet/MealRecommendScreen.tsx +++ b/src/screens/diet/MealRecommendScreen.tsx @@ -1,3031 +1,2805 @@ -// src/screens/diet/MealRecommendScreen.tsx -import React, { useState, useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - TextInput, - 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, userPreferencesAPI } from "../../services"; -import { LinearGradient } from "expo-linear-gradient"; -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) => { - console.log(`🔄 ${dayIndex}일차 변환 시작`); - - 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) { - console.log(`⚠️ ${dayIndex}일차는 SNACK만 있음 - 변환 시작`); - - 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: "저녁", - }; - } - - console.log( - `✅ SNACK 변환 완료: 아침=${!!breakfast}, 점심=${!!lunch}, 저녁=${!!dinner}` - ); - } - } - - 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 [screen, setScreen] = useState< - "welcome" | "excludedIngredients" | "meals" - >("welcome"); - - const [weeklyMeals, setWeeklyMeals] = useState([]); - const [currentDay, setCurrentDay] = useState(0); - const [excludedIngredients, setExcludedIngredients] = useState([]); - const [newIngredient, setNewIngredient] = useState(""); - const [loading, setLoading] = useState(false); - const [savedMeals, setSavedMeals] = useState([]); - const [currentPlanId, setCurrentPlanId] = useState(null); - - // ✅ 끼니 수 관련 state - 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]); - - useEffect(() => { - const loadData = async () => { - try { - console.log("========== GET 테스트 시작 =========="); - try { - const result = await userPreferencesAPI.getUserPreferences(); - console.log("✅ GET 성공:", result); - console.log("✅ 비선호 음식:", result.dislikedFoods); - } catch (testError) { - console.error("❌ GET 실패:", testError); - } - console.log("========== GET 테스트 완료 =========="); - - const dislikedFoods = await userPreferencesAPI.getDislikedFoods(); - setExcludedIngredients(dislikedFoods); - - console.log("✅ 비선호 음식 로드 완료:", dislikedFoods.length, "개"); - - await loadSavedMeals(); - } catch (error) { - console.error("데이터 로드 실패:", error); - try { - const stored = await AsyncStorage.getItem("excludedIngredients"); - if (stored) { - setExcludedIngredients(JSON.parse(stored)); - } - } catch (e) { - console.error("로컬 스토리지 읽기 실패:", e); - } - } - }; - loadData(); - }, []); - - const loadSavedMeals = async () => { - try { - const localStored = await AsyncStorage.getItem("savedMealPlans"); - const localMeals = localStored ? JSON.parse(localStored) : []; - - const serverPlans = await recommendedMealAPI.getSavedMealPlans(); - - console.log("📦 서버에서 받은 plans:", serverPlans.length); - - 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}일 식단`, - })); - - console.log("✅ 그룹화된 서버 번들:", serverBundles.length); - - const allMeals = [...localMeals, ...serverBundles]; - - console.log("📋 저장된 식단:", { - 로컬: localMeals.length, - 서버: serverBundles.length, - 합계: allMeals.length, - }); - - 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("🍽️ 임시 식단 생성 시작"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - - const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); - - console.log("=== 📦 API 응답 원본 ==="); - console.log("응답 배열 길이:", tempMeals.length); - console.log("전체 응답:", JSON.stringify(tempMeals, null, 2)); - - tempMeals.forEach((day, index) => { - console.log(`\n=== ${index + 1}일차 상세 ===`); - console.log("dayIndex:", day.dayIndex); - console.log("meals 배열:", day.meals); - console.log("meals 길이:", day.meals?.length || 0); - - day.meals?.forEach((meal, mealIdx) => { - console.log(` - ${meal.mealType}:`, { - id: meal.id, - foods개수: meal.foods?.length || 0, - totalCalories: meal.totalCalories, - }); - }); - }); - - if (!tempMeals || tempMeals.length === 0) { - throw new Error("식단 생성에 실패했습니다."); - } - - console.log(`✅ ${tempMeals.length}일치 임시 식단 생성 완료`); - - const weekData = tempMeals.map((tempDay, index) => { - const transformed = transformTempMealToUI(tempDay, index + 1); - - console.log(`\n=== ${index + 1}일차 변환 후 ===`); - console.log("totalCalories:", transformed.totalCalories); - console.log("아침 음식 수:", transformed.breakfast.meals.length); - console.log("점심 음식 수:", transformed.lunch.meals.length); - console.log("저녁 음식 수:", transformed.dinner.meals.length); - - return transformed; - }); - - console.log("\n=== 📊 최종 weekData ==="); - console.log("weekData 길이:", weekData.length); - weekData.forEach((day, idx) => { - console.log(`${idx + 1}일차:`, { - totalCalories: day.totalCalories, - 아침: day.breakfast.meals.length, - 점심: day.lunch.meals.length, - 저녁: day.dinner.meals.length, - }); - }); - - setWeeklyMeals(weekData); - setCurrentPlanId(null); - setScreen("meals"); - 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일 식단 생성 시작"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - - 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); - Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleAddExcludedIngredient = async () => { - const trimmed = newIngredient.trim(); - - if (!trimmed) { - return; - } - - if (excludedIngredients.includes(trimmed)) { - Alert.alert("알림", "이미 추가된 식재료입니다."); - return; - } - - try { - setLoading(true); - - try { - const result = await userPreferencesAPI.addDislikedFoods( - excludedIngredients, - [trimmed] - ); - - setExcludedIngredients(result.updatedList); - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(result.updatedList) - ); - - setNewIngredient(""); - console.log("✅ 비선호 음식 추가 완료 (서버):", result.updatedList); - return; - } catch (serverError: any) { - console.warn("⚠️ 서버 저장 실패, 로컬만 저장:", serverError.message); - - if ( - serverError.message?.includes("서버 내부 오류") || - serverError.status === 500 - ) { - const updatedList = [...excludedIngredients, trimmed]; - setExcludedIngredients(updatedList); - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(updatedList) - ); - - setNewIngredient(""); - Alert.alert( - "일부 성공", - "식재료가 기기에 저장되었습니다.\n(서버 동기화는 백엔드 수정 후 가능합니다)" - ); - console.log("✅ 비선호 음식 추가 완료 (로컬만):", updatedList); - return; - } - - throw serverError; - } - } catch (error: any) { - console.error("비선호 음식 추가 실패:", error); - Alert.alert("오류", error.message || "식재료 추가에 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleRemoveExcludedIngredient = async (ingredient: string) => { - Alert.alert("삭제", `"${ingredient}"를 삭제하시겠습니까?`, [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - const result = await userPreferencesAPI.removeDislikedFood( - excludedIngredients, - ingredient - ); - - setExcludedIngredients(result.updatedList); - - await AsyncStorage.setItem( - "excludedIngredients", - JSON.stringify(result.updatedList) - ); - - console.log("✅ 비선호 음식 삭제 완료:", result.updatedList); - } catch (error: any) { - console.error("비선호 음식 삭제 실패:", error); - Alert.alert("오류", error.message || "식재료 삭제에 실패했습니다."); - } 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; - - console.log( - `${mealArray[mealIndex].liked ? "💚" : "🤍"} ${ - mealArray[mealIndex].name - } 좋아요 ${mealArray[mealIndex].liked ? "활성화" : "비활성화"}` - ); - - // TODO: 나중에 API 연결 - // if (mealArray[mealIndex].liked) { - // await userPreferencesAPI.addLikedFood(mealArray[mealIndex].name); - // } else { - // await userPreferencesAPI.removeLikedFood(mealArray[mealIndex].name); - // } - - return updated; - }); - }; - - 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 handleSaveMealPlanLocally = async () => { - try { - setLoading(true); - - const mealsForHistory = weeklyMeals.map((d) => ({ - totalCalories: d.totalCalories, - carbs: d.carbs, - protein: d.protein, - fat: d.fat, - breakfast: d.breakfast, - lunch: d.lunch, - dinner: d.dinner, - })); - - const mealPlanToSave = { - id: `local_${Date.now()}`, - date: new Date().toLocaleDateString("ko-KR"), - planName: weeklyMeals[0]?.planName || "AI 식단", - description: weeklyMeals[0]?.description || "AI가 생성한 식단", - totalCalories: weeklyMeals[0]?.totalCalories || 0, - totalCarbs: weeklyMeals[0]?.carbs || 0, - totalProtein: weeklyMeals[0]?.protein || 0, - totalFat: weeklyMeals[0]?.fat || 0, - createdAt: new Date().toISOString(), - meals: mealsForHistory, - isLocalMeal: true, - }; - - const stored = await AsyncStorage.getItem("savedMealPlans"); - const existingMeals = stored ? JSON.parse(stored) : []; - - const updatedMeals = [mealPlanToSave, ...existingMeals].slice(0, 20); - await AsyncStorage.setItem( - "savedMealPlans", - JSON.stringify(updatedMeals) - ); - - console.log("💾 로컬 저장 완료:", mealPlanToSave.id); - - Alert.alert("저장 완료", "식단이 기기에 저장되었습니다! 🎉", [ - { - text: "확인", - onPress: async () => { - await loadSavedMeals(); - setScreen("welcome"); - }, - }, - ]); - } catch (error: any) { - console.error("로컬 저장 실패:", error); - Alert.alert("오류", "식단 저장에 실패했습니다."); - } finally { - setLoading(false); - } - }; - - const handleSaveMealPlan = async () => { - try { - setLoading(true); - 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) - ); - - console.log("🗑️ 로컬 식단 삭제:", meal.id); - } else { - console.log("🗑️ 서버 번들 삭제:", meal.bundleId || meal.id); - 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 ( - - - - - - - {/* ✅ 끼니 선택 모달 */} - { - setMealsPerDay(num); - console.log(`✅ 끼니 수 변경: ${num}끼`); - }} - onClose={() => setShowMealsModal(false)} - /> - - - navigation.goBack()} - style={styles.backButton} - > - - - - - - - - - - - 🥗 - - - 맞춤 식단 추천 - - AI가 당신만을 위한{"\n"}완벽한 식단을 설계합니다 - - - - - {/* 1. 7일 추천 식단 받기 */} - - - - - 7일 추천 식단 받기 - - - - - {/* 2. 1일 추천 식단 받기 */} - - - - - 1일 추천 식단 받기 - - - - - {/* 3. 금지 식재료 관리 */} - setScreen("excludedIngredients")} - activeOpacity={0.8} - > - - - - 금지 식재료 관리 - {excludedIngredients.length > 0 && - ` (${excludedIngredients.length})`} - - - - - {/* ✅ 4. 끼니 수정하기 */} - setShowMealsModal(true)} - activeOpacity={0.8} - > - - - - 끼니 수정하기 ({mealsPerDay}끼) - - - - - - {excludedIngredients.length > 0 && ( - - - 제외된 식재료 - - {excludedIngredients.map((ingredient, index) => ( - - - {ingredient} - - - ))} - - - - )} - - {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} - > - - - - - - ); - })} - - )} - - - - ); - } - - if (screen === "excludedIngredients") { - return ( - - - - - - setScreen("welcome")} - style={styles.backButton} - > - - - - - 금지 식재료 - - - - - - - - - - - - - - - - - - - - {excludedIngredients.map((ingredient, index) => ( - - - - - - - {ingredient} - - handleRemoveExcludedIngredient(ingredient)} - activeOpacity={0.7} - > - - - - - ))} - - {excludedIngredients.length === 0 && ( - - - - 등록된 금지 식재료가 없습니다 - - - 알러지나 선호하지 않는 식재료를 추가하세요 - - - )} - - - setScreen("welcome")} - activeOpacity={0.9} - > - - - 완료 - - - - - - ); - } - - 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", - }, - excludedForm: { - flex: 1, - }, - excludedFormContent: { - paddingHorizontal: 20, - paddingTop: 30, - paddingBottom: 40, - }, - inputGroup: { - flexDirection: "row", - gap: 12, - marginBottom: 30, - }, - inputWrapper: { - flex: 1, - borderRadius: 16, - overflow: "hidden", - }, - inputGradient: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 16, - paddingVertical: 4, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 16, - }, - inputIcon: { - marginRight: 12, - }, - textInput: { - flex: 1, - height: 52, - color: "#ffffff", - fontSize: 15, - letterSpacing: 0.3, - }, - addButton: { - width: 56, - height: 56, - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 6, - }, - addButtonGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - excludedList: { - gap: 12, - marginBottom: 30, - minHeight: 200, - }, - excludedItem: { - borderRadius: 14, - overflow: "hidden", - }, - excludedItemGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: 16, - paddingHorizontal: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 14, - }, - excludedItemLeft: { - flexDirection: "row", - alignItems: "center", - flex: 1, - gap: 12, - }, - excludedItemIcon: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: "rgba(239,68,68,0.1)", - alignItems: "center", - justifyContent: "center", - }, - excludedItemText: { - fontSize: 16, - fontWeight: "500", - color: "#ffffff", - flex: 1, - letterSpacing: 0.3, - }, - removeButton: { - width: 32, - height: 32, - alignItems: "center", - justifyContent: "center", - }, - emptyState: { - alignItems: "center", - justifyContent: "center", - paddingVertical: 60, - paddingHorizontal: 40, - }, - emptyMessage: { - textAlign: "center", - color: "#6b7280", - fontSize: 16, - fontWeight: "600", - marginTop: 16, - marginBottom: 8, - letterSpacing: 0.3, - }, - emptySubtext: { - textAlign: "center", - color: "#4b5563", - fontSize: 14, - letterSpacing: 0.2, - }, - completeButton: { - borderRadius: 16, - overflow: "hidden", - shadowColor: "#E3FF7C", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - completeButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: 18, - }, - completeButtonText: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, - mealDateContainer: { - paddingHorizontal: 20, - paddingTop: 16, - 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, + TextInput, + 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, userPreferencesAPI } from "../../services"; +import { LinearGradient } from "expo-linear-gradient"; +import DislikedFoodsModal from "../../components/modals/DislikedFoodsModal"; + +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) => { + console.log(`🔄 ${dayIndex}일차 변환 시작`); + + 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) { + console.log(`⚠️ ${dayIndex}일차는 SNACK만 있음 - 변환 시작`); + + 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: "저녁", + }; + } + + console.log( + `✅ SNACK 변환 완료: 아침=${!!breakfast}, 점심=${!!lunch}, 저녁=${!!dinner}` + ); + } + } + + 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 [screen, setScreen] = useState<"welcome" | "meals">("welcome"); + const [showDislikedModal, setShowDislikedModal] = useState(false); + + const [weeklyMeals, setWeeklyMeals] = useState([]); + const [currentDay, setCurrentDay] = useState(0); + const [excludedIngredients, setExcludedIngredients] = useState([]); + const [newIngredient, setNewIngredient] = useState(""); + const [loading, setLoading] = useState(false); + const [savedMeals, setSavedMeals] = useState([]); + const [currentPlanId, setCurrentPlanId] = useState(null); + + // ✅ 끼니 수 관련 state + 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]); + + useEffect(() => { + const loadData = async () => { + try { + console.log("========== GET 테스트 시작 =========="); + try { + const result = await userPreferencesAPI.getUserPreferences(); + console.log("✅ GET 성공:", result); + console.log("✅ 비선호 음식:", result.dislikedFoods); + } catch (testError) { + console.error("❌ GET 실패:", testError); + } + console.log("========== GET 테스트 완료 =========="); + + const dislikedFoods = await userPreferencesAPI.getDislikedFoods(); + setExcludedIngredients(dislikedFoods); + + console.log("✅ 비선호 음식 로드 완료:", dislikedFoods.length, "개"); + + await loadSavedMeals(); + } catch (error) { + console.error("데이터 로드 실패:", error); + try { + const stored = await AsyncStorage.getItem("excludedIngredients"); + if (stored) { + setExcludedIngredients(JSON.parse(stored)); + } + } catch (e) { + console.error("로컬 스토리지 읽기 실패:", e); + } + } + }; + loadData(); + }, []); + + const loadSavedMeals = async () => { + try { + const localStored = await AsyncStorage.getItem("savedMealPlans"); + const localMeals = localStored ? JSON.parse(localStored) : []; + + const serverPlans = await recommendedMealAPI.getSavedMealPlans(); + + console.log("📦 서버에서 받은 plans:", serverPlans.length); + + 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}일 식단`, + })); + + console.log("✅ 그룹화된 서버 번들:", serverBundles.length); + + const allMeals = [...localMeals, ...serverBundles]; + + console.log("📋 저장된 식단:", { + 로컬: localMeals.length, + 서버: serverBundles.length, + 합계: allMeals.length, + }); + + 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("🍽️ 임시 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + + const tempMeals = await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); + + console.log("=== 📦 API 응답 원본 ==="); + console.log("응답 배열 길이:", tempMeals.length); + console.log("전체 응답:", JSON.stringify(tempMeals, null, 2)); + + tempMeals.forEach((day, index) => { + console.log(`\n=== ${index + 1}일차 상세 ===`); + console.log("dayIndex:", day.dayIndex); + console.log("meals 배열:", day.meals); + console.log("meals 길이:", day.meals?.length || 0); + + day.meals?.forEach((meal, mealIdx) => { + console.log(` - ${meal.mealType}:`, { + id: meal.id, + foods개수: meal.foods?.length || 0, + totalCalories: meal.totalCalories, + }); + }); + }); + + if (!tempMeals || tempMeals.length === 0) { + throw new Error("식단 생성에 실패했습니다."); + } + + console.log(`✅ ${tempMeals.length}일치 임시 식단 생성 완료`); + + const weekData = tempMeals.map((tempDay, index) => { + const transformed = transformTempMealToUI(tempDay, index + 1); + + console.log(`\n=== ${index + 1}일차 변환 후 ===`); + console.log("totalCalories:", transformed.totalCalories); + console.log("아침 음식 수:", transformed.breakfast.meals.length); + console.log("점심 음식 수:", transformed.lunch.meals.length); + console.log("저녁 음식 수:", transformed.dinner.meals.length); + + return transformed; + }); + + console.log("\n=== 📊 최종 weekData ==="); + console.log("weekData 길이:", weekData.length); + weekData.forEach((day, idx) => { + console.log(`${idx + 1}일차:`, { + totalCalories: day.totalCalories, + 아침: day.breakfast.meals.length, + 점심: day.lunch.meals.length, + 저녁: day.dinner.meals.length, + }); + }); + + setWeeklyMeals(weekData); + setCurrentPlanId(null); + setScreen("meals"); + 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일 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + + 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); + Alert.alert("오류", error.message || "식단을 불러오는데 실패했습니다."); + } 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; + + console.log( + `${mealArray[mealIndex].liked ? "💚" : "🤍"} ${ + mealArray[mealIndex].name + } 좋아요 ${mealArray[mealIndex].liked ? "활성화" : "비활성화"}` + ); + + // TODO: 나중에 API 연결 + // if (mealArray[mealIndex].liked) { + // await userPreferencesAPI.addLikedFood(mealArray[mealIndex].name); + // } else { + // await userPreferencesAPI.removeLikedFood(mealArray[mealIndex].name); + // } + + return updated; + }); + }; + + 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 handleSaveMealPlanLocally = async () => { + try { + setLoading(true); + + const mealsForHistory = weeklyMeals.map((d) => ({ + totalCalories: d.totalCalories, + carbs: d.carbs, + protein: d.protein, + fat: d.fat, + breakfast: d.breakfast, + lunch: d.lunch, + dinner: d.dinner, + })); + + const mealPlanToSave = { + id: `local_${Date.now()}`, + date: new Date().toLocaleDateString("ko-KR"), + planName: weeklyMeals[0]?.planName || "AI 식단", + description: weeklyMeals[0]?.description || "AI가 생성한 식단", + totalCalories: weeklyMeals[0]?.totalCalories || 0, + totalCarbs: weeklyMeals[0]?.carbs || 0, + totalProtein: weeklyMeals[0]?.protein || 0, + totalFat: weeklyMeals[0]?.fat || 0, + createdAt: new Date().toISOString(), + meals: mealsForHistory, + isLocalMeal: true, + }; + + const stored = await AsyncStorage.getItem("savedMealPlans"); + const existingMeals = stored ? JSON.parse(stored) : []; + + const updatedMeals = [mealPlanToSave, ...existingMeals].slice(0, 20); + await AsyncStorage.setItem( + "savedMealPlans", + JSON.stringify(updatedMeals) + ); + + console.log("💾 로컬 저장 완료:", mealPlanToSave.id); + + Alert.alert("저장 완료", "식단이 기기에 저장되었습니다! 🎉", [ + { + text: "확인", + onPress: async () => { + await loadSavedMeals(); + setScreen("welcome"); + }, + }, + ]); + } catch (error: any) { + console.error("로컬 저장 실패:", error); + Alert.alert("오류", "식단 저장에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSaveMealPlan = async () => { + try { + setLoading(true); + 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) + ); + + console.log("🗑️ 로컬 식단 삭제:", meal.id); + } else { + console.log("🗑️ 서버 번들 삭제:", meal.bundleId || meal.id); + 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 ( + + + + + + + {/* ✅ 끼니 선택 모달 */} + { + setMealsPerDay(num); + console.log(`✅ 끼니 수 변경: ${num}끼`); + }} + onClose={() => setShowMealsModal(false)} + /> + + + navigation.goBack()} + style={styles.backButton} + > + + + + + + + + + + + 🥗 + + + 맞춤 식단 추천 + + AI가 당신만을 위한{"\n"}완벽한 식단을 설계합니다 + + + + + {/* 1. 7일 추천 식단 받기 */} + + + + + 7일 추천 식단 받기 + + + + + {/* 2. 1일 추천 식단 받기 */} + + + + + 1일 추천 식단 받기 + + + + + {/* 3. 금지 식재료 관리 */} + setShowDislikedModal(true)} // 변경 + activeOpacity={0.8} + > + + + + 금지 식재료 관리 + {excludedIngredients.length > 0 && + ` (${excludedIngredients.length})`} + + + + + {/* ✅ 4. 끼니 수정하기 */} + setShowMealsModal(true)} + activeOpacity={0.8} + > + + + + 끼니 수정하기 ({mealsPerDay}끼) + + + + + + {excludedIngredients.length > 0 && ( + + + 제외된 식재료 + + {excludedIngredients.map((ingredient, index) => ( + + + {ingredient} + + + ))} + + + + )} + + {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", + }, + excludedForm: { + flex: 1, + }, + excludedFormContent: { + paddingHorizontal: 20, + paddingTop: 30, + paddingBottom: 40, + }, + inputGroup: { + flexDirection: "row", + gap: 12, + marginBottom: 30, + }, + inputWrapper: { + flex: 1, + borderRadius: 16, + overflow: "hidden", + }, + inputGradient: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 4, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 16, + }, + inputIcon: { + marginRight: 12, + }, + textInput: { + flex: 1, + height: 52, + color: "#ffffff", + fontSize: 15, + letterSpacing: 0.3, + }, + addButton: { + width: 56, + height: 56, + borderRadius: 16, + overflow: "hidden", + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + addButtonGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + excludedList: { + gap: 12, + marginBottom: 30, + minHeight: 200, + }, + excludedItem: { + borderRadius: 14, + overflow: "hidden", + }, + excludedItemGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 16, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 14, + }, + excludedItemLeft: { + flexDirection: "row", + alignItems: "center", + flex: 1, + gap: 12, + }, + excludedItemIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: "rgba(239,68,68,0.1)", + alignItems: "center", + justifyContent: "center", + }, + excludedItemText: { + fontSize: 16, + fontWeight: "500", + color: "#ffffff", + flex: 1, + letterSpacing: 0.3, + }, + removeButton: { + width: 32, + height: 32, + alignItems: "center", + justifyContent: "center", + }, + emptyState: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 60, + paddingHorizontal: 40, + }, + emptyMessage: { + textAlign: "center", + color: "#6b7280", + fontSize: 16, + fontWeight: "600", + marginTop: 16, + marginBottom: 8, + letterSpacing: 0.3, + }, + emptySubtext: { + textAlign: "center", + color: "#4b5563", + fontSize: 14, + letterSpacing: 0.2, + }, + completeButton: { + borderRadius: 16, + overflow: "hidden", + shadowColor: "#E3FF7C", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + completeButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 18, + }, + completeButtonText: { + fontSize: 17, + fontWeight: "700", + color: "#111827", + letterSpacing: 0.5, + }, + mealDateContainer: { + paddingHorizontal: 20, + paddingTop: 16, + 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 c5f15a3..54a4d34 100644 --- a/src/screens/diet/TempMealRecommendScreen.tsx +++ b/src/screens/diet/TempMealRecommendScreen.tsx @@ -7,6 +7,7 @@ import { StyleSheet, ScrollView, TouchableOpacity, + TextInput, Alert, ActivityIndicator, Dimensions, @@ -20,6 +21,7 @@ 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 { userPreferencesAPI, recommendedMealAPI } from "../../services"; import { TempDayMeal } from "../../services/recommendedMealAPI"; @@ -174,6 +176,227 @@ const LoadingOverlay = ({ ); }; +// ✅ 끼니 선택 모달 +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; @@ -194,15 +417,22 @@ interface FoodItem { const TempMealRecommendScreen: React.FC = () => { const navigation = useNavigation(); - const [screen, setScreen] = useState<"input" | "result">("input"); + 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); + // ✅ 끼니 수 관련 state + const [mealsPerDay, setMealsPerDay] = useState(3); + const [showMealsModal, setShowMealsModal] = useState(false); + // 토큰 부족 상태 관리 const [isTokenDepleted, setIsTokenDepleted] = useState(false); @@ -213,6 +443,16 @@ const TempMealRecommendScreen: React.FC = () => { fat: 0, }); + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }).start(); + }, [screen]); + useEffect(() => { loadUserData(); }, []); @@ -223,8 +463,22 @@ const TempMealRecommendScreen: React.FC = () => { if (preferences.dislikedFoods) { setExcludedFoods(preferences.dislikedFoods); } + console.log( + "✅ 비선호 음식 로드 완료:", + preferences.dislikedFoods?.length || 0, + "개" + ); } catch (error) { console.error("사용자 데이터 로드 실패:", error); + // 로컬 스토리지에서 불러오기 시도 + try { + const stored = await AsyncStorage.getItem("excludedIngredients"); + if (stored) { + setExcludedFoods(JSON.parse(stored)); + } + } catch (e) { + console.error("로컬 스토리지 읽기 실패:", e); + } } }; @@ -250,8 +504,11 @@ const TempMealRecommendScreen: React.FC = () => { try { setLoading(true); + console.log("🍽️ 임시 식단 생성 시작"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + const weeklyData: TempDayMeal[] = - await recommendedMealAPI.getWeeklyMealPlan(); + await recommendedMealAPI.getWeeklyMealPlan(mealsPerDay); if (!weeklyData || weeklyData.length === 0) { throw new Error("추천된 식단 데이터가 없습니다."); @@ -379,42 +636,145 @@ const TempMealRecommendScreen: React.FC = () => { } }; - return ( - - + // ✅ 금지 식재료 추가 + const handleAddExcludedIngredient = async () => { + const trimmed = newIngredient.trim(); - - { + Alert.alert("삭제", `"${ingredient}"를 삭제하시겠습니까?`, [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + const result = await userPreferencesAPI.removeDislikedFood( + excludedFoods, + ingredient + ); + + setExcludedFoods(result.updatedList); + + await AsyncStorage.setItem( + "excludedIngredients", + JSON.stringify(result.updatedList) + ); + + console.log("✅ 비선호 음식 삭제 완료:", result.updatedList); + } catch (error: any) { + console.error("비선호 음식 삭제 실패:", error); + Alert.alert("오류", error.message || "식재료 삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ]); + }; + + // ✅ input 화면 (웰컴 화면) + if (screen === "input") { + return ( + + - - { - if (screen === "result") { - setScreen("input"); - } else { - navigation.goBack(); - } + + + + {/* ✅ 끼니 선택 모달 */} + { + setMealsPerDay(num); + console.log(`✅ 끼니 수 변경: ${num}끼`); }} - style={styles.backButton} - > - - - - - - {screen === "result" ? "추천 결과" : "식단 추천"} - - - + onClose={() => setShowMealsModal(false)} + /> + + + navigation.goBack()} + style={styles.backButton} + > + + + + + 식단 추천 + + - {screen === "input" && ( { + {/* ✅ 금지 식재료 관리 버튼 */} + setScreen("excludedIngredients")} + activeOpacity={0.8} + > + + + + 금지 식재료 관리 + {excludedFoods.length > 0 && ` (${excludedFoods.length})`} + + + + + {/* ✅ 끼니 수정하기 버튼 */} + setShowMealsModal(true)} + activeOpacity={0.8} + > + + + + 끼니 수정하기 ({mealsPerDay}끼) + + + + + {/* 금지 식재료 미리보기 */} + {excludedFoods.length > 0 && ( + + + 제외된 식재료 + + {excludedFoods.map((ingredient, index) => ( + + + {ingredient} + + + ))} + + + + )} + @@ -516,13 +945,7 @@ const TempMealRecommendScreen: React.FC = () => { Alert.alert( "프리미엄 기능", "7일 식단 추천은 프리미엄 전용입니다.\n업그레이드 하시겠습니까?", - [ - { text: "취소", style: "cancel" }, - { - text: "이동", - onPress: () => navigation.navigate("MyPage" as never), - }, - ] + [{ text: "취소", style: "cancel" }] ) } activeOpacity={0.8} @@ -624,216 +1047,359 @@ const TempMealRecommendScreen: React.FC = () => { )} - )} - - {screen === "result" && ( - - - - {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}일차 - - )} - - ); - })} - - + // ✅ 금지 식재료 관리 화면 + if (screen === "excludedIngredients") { + return ( + + - + + setScreen("input")} + style={styles.backButton} > - + + + + + 금지 식재료 + + + + + + - - 오늘의 영양 섭취 - - - - {dailyNutrition.calories} kcal - - - - - - - 탄수화물 - - {dailyNutrition.carbs}g - - - - - 단백질 - - {dailyNutrition.protein}g - - - - - 지방 - - {dailyNutrition.fat}g - - - + + + + + + + + - {recommendedMeals.map((meal, index) => ( - + + {excludedFoods.map((ingredient, index) => ( + - - - - - - {meal.mealTypeName === "아침" - ? "🌅" - : meal.mealTypeName === "점심" - ? "☀️" - : "🌙"} - - - - - - {meal.mealTypeName} - - + + + + + {ingredient} + + handleRemoveExcludedIngredient(ingredient)} + 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 === "아침" - ? "07:00 - 09:00" + ? "🌅" : meal.mealTypeName === "점심" - ? "12:00 - 14:00" - : "18:00 - 20:00"} + ? "☀️" + : "🌙"} - + - - - {meal.totalCalories} + + {meal.mealTypeName} + + {meal.mealTypeName === "아침" + ? "07:00 - 09:00" + : meal.mealTypeName === "점심" + ? "12:00 - 14:00" + : "18:00 - 20:00"} - kcal - - - {meal.foods.map((food, fIdx) => ( - - - - {food.foodName} {food.servingSize}g - - - ({food.calories}kcal) - - - - ))} + + + {meal.totalCalories} + + kcal - - - ))} + - - + {meal.foods.map((food, fIdx) => ( + + + + {food.foodName} {food.servingSize}g + + + ({food.calories}kcal) + + + + ))} + + + + ))} + + + + - - - 이 식단 저장하기 - - + + 이 식단 저장하기 + + - setScreen("input")} - > - 다시 설정하기 - - - - - )} + setScreen("input")} + > + 다시 설정하기 + + + + ); @@ -973,6 +1539,68 @@ const styles = StyleSheet.create({ }, 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", @@ -1068,6 +1696,150 @@ const styles = StyleSheet.create({ 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 }, diff --git a/src/screens/exercise/RoutineRecommendScreen.tsx b/src/screens/exercise/RoutineRecommendScreen.tsx index f05395c..86b61a3 100644 --- a/src/screens/exercise/RoutineRecommendScreen.tsx +++ b/src/screens/exercise/RoutineRecommendScreen.tsx @@ -1,495 +1,778 @@ -import React, { useState, useEffect } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Alert, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { useFocusEffect } from "@react-navigation/native"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import AsyncStorage from "@react-native-async-storage/async-storage"; - -const RoutineRecommendScreen = ({ navigation }: any) => { - const [savedRoutines, setSavedRoutines] = useState([]); - const [selectedRoutine, setSelectedRoutine] = useState(null); - const [selectedDay, setSelectedDay] = useState(0); - - const weekDays = [ - "1일차", - "2일차", - "3일차", - "4일차", - "5일차", - "6일차", - "7일차", - ]; - - const loadRoutines = async () => { - try { - const stored = await AsyncStorage.getItem("savedRoutines"); - if (stored) { - setSavedRoutines(JSON.parse(stored)); - } else { - setSavedRoutines([]); - } - } catch (error) { - console.log("Failed to load routines", error); - } - }; - - useFocusEffect( - React.useCallback(() => { - loadRoutines(); - }, []) - ); - - const handleRoutineClick = (routine: any) => { - setSelectedRoutine(routine); - setSelectedDay(0); - }; - - const handleBack = () => { - setSelectedRoutine(null); - setSelectedDay(0); - }; - - const handleDelete = async (routineId: number) => { - Alert.alert("삭제", "이 루틴을 삭제하시겠습니까?", [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - const updated = savedRoutines.filter((r) => r.id !== routineId); - await AsyncStorage.setItem("savedRoutines", JSON.stringify(updated)); - setSavedRoutines(updated); - if (selectedRoutine && selectedRoutine.id === routineId) { - setSelectedRoutine(null); - setSelectedDay(0); - } - }, - }, - ]); - }; - - return ( - - - navigation.goBack()}> - - - - {selectedRoutine ? "루틴 상세보기" : "운동 추천 내역"} - - - - - - {!selectedRoutine ? ( - savedRoutines.length === 0 ? ( - - 저장된 운동 루틴이 없습니다. - - 운동 추천을 받고 루틴을 저장해보세요! - - navigation.navigate("RoutineRecommendNew")} - > - - 추천받으러 가기 → - - - - ) : ( - - navigation.navigate("RoutineRecommendNew")} - > - 새 운동 추천받기 - - {savedRoutines.map((routine) => ( - handleRoutineClick(routine)} - activeOpacity={0.98} - > - - - 📅 - {routine.date} - - handleDelete(routine.id)} - style={styles.deleteBtn} - activeOpacity={0.9} - > - 🗑️ - - - - {routine.level && ( - - {routine.level} - - )} - {routine.targetParts && routine.targetParts.length > 0 && ( - - - 집중: {routine.targetParts.join(", ")} - - - )} - {routine.weakParts && routine.weakParts.length > 0 && ( - - - 주의: {routine.weakParts.join(", ")} - - - )} - - - 자세히 보기 → - - - ))} - - ) - ) : ( - - - ← 목록으로 - - - - {selectedRoutine.date} - - {selectedRoutine.level && ( - - - {selectedRoutine.level} - - - )} - {selectedRoutine.targetParts && - selectedRoutine.targetParts.length > 0 && ( - - - 집중: {selectedRoutine.targetParts.join(", ")} - - - )} - - - - - {weekDays.map((day, index) => ( - setSelectedDay(index)} - activeOpacity={0.8} - > - - {day} - - - ))} - - - - {selectedRoutine.routine?.[selectedDay]?.map( - (exercise: any, index: number) => ( - - - - {exercise.icon || "💪"} - - - - {exercise.name} - - {exercise.detail} - - - - ) - )} - - - )} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#1a1a1a", - }, - header: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 16, - paddingHorizontal: 20, - borderBottomWidth: 1, - borderBottomColor: "#333333", - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - }, - content: { - flex: 1, - padding: 20, - }, - emptyState: { - alignItems: "center", - paddingVertical: 60, - paddingHorizontal: 20, - }, - emptyText: { - fontSize: 16, - color: "#999999", - textAlign: "center", - marginBottom: 10, - }, - emptySubtitle: { - fontSize: 14, - color: "#666666", - textAlign: "center", - marginBottom: 24, - }, - goToRecommendBtn: { - backgroundColor: "#e3ff7c", - paddingVertical: 12, - paddingHorizontal: 24, - borderRadius: 12, - marginTop: 16, - }, - goToRecommendBtnText: { - fontSize: 15, - fontWeight: "600", - color: "#111111", - }, - list: { - gap: 16, - }, - newRecommendBtn: { - backgroundColor: "#e3ff7c", - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignSelf: "flex-start", - marginBottom: 8, - }, - newRecommendBtnText: { - fontSize: 14, - fontWeight: "600", - color: "#111111", - }, - card: { - backgroundColor: "#222222", - borderRadius: 12, - padding: 16, - borderWidth: 2, - borderColor: "transparent", - }, - cardHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 12, - }, - dateContainer: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - dateIcon: { - fontSize: 18, - }, - date: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - }, - deleteBtn: { - padding: 4, - }, - deleteBtnText: { - fontSize: 18, - }, - cardBody: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - marginBottom: 12, - }, - badge: { - paddingVertical: 4, - paddingHorizontal: 10, - borderRadius: 12, - }, - levelBadge: { - backgroundColor: "#e3ff7c", - }, - targetBadge: { - backgroundColor: "#4a90e2", - }, - weakBadge: { - backgroundColor: "#ff6b6b", - }, - badgeText: { - fontSize: 12, - fontWeight: "500", - color: "#111111", - }, - targetBadgeText: { - fontSize: 12, - fontWeight: "500", - color: "#ffffff", - }, - weakBadgeText: { - fontSize: 12, - fontWeight: "500", - color: "#ffffff", - }, - cardFooter: { - alignItems: "flex-end", - }, - viewDetail: { - fontSize: 14, - color: "#e3ff7c", - fontWeight: "500", - }, - detail: { - gap: 20, - }, - backBtn: { - backgroundColor: "#2a2a2a", - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 8, - alignSelf: "flex-start", - }, - backBtnText: { - fontSize: 14, - fontWeight: "500", - color: "#ffffff", - }, - detailInfo: { - backgroundColor: "#222222", - padding: 16, - borderRadius: 12, - }, - detailDate: { - fontSize: 18, - fontWeight: "700", - color: "#ffffff", - marginBottom: 12, - }, - detailBadges: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - }, - detailBadge: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 12, - backgroundColor: "#e3ff7c", - }, - detailBadgeText: { - fontSize: 13, - fontWeight: "500", - color: "#111111", - }, - dayTabsContainer: { - marginVertical: 8, - }, - dayTabs: { - gap: 8, - paddingBottom: 8, - }, - dayTab: { - paddingVertical: 8, - paddingHorizontal: 16, - backgroundColor: "#222222", - borderRadius: 20, - }, - dayTabActive: { - backgroundColor: "#e3ff7c", - }, - dayTabText: { - fontSize: 14, - fontWeight: "500", - color: "#999999", - }, - dayTabTextActive: { - color: "#111111", - fontWeight: "600", - }, - exerciseList: { - gap: 12, - }, - exerciseItem: { - backgroundColor: "#2a2a2a", - borderRadius: 12, - padding: 16, - flexDirection: "row", - alignItems: "center", - gap: 15, - }, - exerciseIcon: { - width: 50, - height: 50, - borderRadius: 10, - backgroundColor: "#333333", - justifyContent: "center", - alignItems: "center", - }, - exerciseIconText: { - fontSize: 32, - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - marginBottom: 5, - }, - exerciseDetail: { - fontSize: 14, - color: "#aaaaaa", - }, -}); - -export default RoutineRecommendScreen; +import React, { useState } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useFocusEffect } from "@react-navigation/native"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { recommendedExerciseAPI } from "../../services/recommendedExerciseAPI"; +import { authAPI } from "../../services"; +type Exercise = { + exerciseId?: string; + name: string; + target?: string; + sets?: number; + reps?: number; + weight?: number; +}; + +type ExercisePlan = { + planId: string; + planName: string; + createdAt: string; + description?: string; + days: Exercise[][]; + isServerPlan: boolean; +}; + +const RoutineRecommendScreen = ({ navigation }: any) => { + const [plans, setPlans] = useState([]); + const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedDay, setSelectedDay] = useState(0); + const [loading, setLoading] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [selectedPlanIds, setSelectedPlanIds] = useState([]); + + const loadExercisePlans = async () => { + try { + setLoading(true); + + // ✅ 추천받은 운동 조회 + const exercises = await recommendedExerciseAPI.getRecommendedExercises(); + + console.log("📥 추천받은 운동:", exercises); + + if (!Array.isArray(exercises) || exercises.length === 0) { + setPlans([]); + return; + } + + // ✅ 임시: 타겟별로 그룹화 + const groupedByTarget = exercises.reduce((acc: any, exercise: any) => { + const target = exercise.target || "전신"; + if (!acc[target]) { + acc[target] = []; + } + acc[target].push(exercise); + return acc; + }, {}); + + // ✅ 각 타겟별로 플랜 생성 + const formattedPlans: ExercisePlan[] = Object.entries( + groupedByTarget + ).map(([target, exs]: [string, any], index) => ({ + planId: `plan_${target}_${Date.now()}_${index}`, + planName: `AI 추천 운동 - ${target}`, + createdAt: new Date().toISOString(), + description: `${target} 집중 운동`, + days: [exs], // 1일치 + isServerPlan: true, + })); + + console.log("✅ 변환된 플랜:", formattedPlans.length); + setPlans(formattedPlans); + } catch (error: any) { + console.error("Failed to load exercise plans", error); + Alert.alert( + "오류", + error.message || "운동 추천 내역을 불러오는데 실패했습니다." + ); + setPlans([]); + } finally { + setLoading(false); + } + }; + + useFocusEffect( + React.useCallback(() => { + loadExercisePlans(); + setIsEditMode(false); + setSelectedPlanIds([]); + }, []) + ); + + const handlePlanClick = (plan: ExercisePlan) => { + if (isEditMode) { + togglePlanSelection(plan.planId); + return; + } + + setSelectedPlan(plan); + setSelectedDay(0); + }; + + const handleBack = () => { + setSelectedPlan(null); + setSelectedDay(0); + }; + + const handleDelete = async (plan: ExercisePlan) => { + Alert.alert( + "삭제", + `"${plan.planName}" 운동 플랜을 삭제하시겠습니까?\n(${plan.days.length}일치)`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + // TODO: 백엔드 삭제 API 추가되면 여기서 호출 + // await recommendedExerciseAPI.deleteExercisePlan(plan.planId); + + setPlans((prev) => prev.filter((p) => p.planId !== plan.planId)); + + if (selectedPlan?.planId === plan.planId) { + setSelectedPlan(null); + } + + Alert.alert("성공", "운동 플랜이 삭제되었습니다."); + } catch (error: any) { + console.error("삭제 실패:", error); + Alert.alert("오류", error.message || "삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const togglePlanSelection = (planId: string) => { + setSelectedPlanIds((prev) => + prev.includes(planId) + ? prev.filter((id) => id !== planId) + : [...prev, planId] + ); + }; + + const toggleSelectAll = () => { + if (selectedPlanIds.length === plans.length) { + setSelectedPlanIds([]); + } else { + setSelectedPlanIds(plans.map((p) => p.planId)); + } + }; + const handleNewRecommendPress = async () => { + try { + setLoading(true); + + // 사용자 프로필 조회 + const profile = await authAPI.getProfile(); + + console.log("✅ 멤버십 타입:", profile.membershipType); + + // 멤버십 타입에 따라 다른 화면으로 이동 + if (profile.membershipType === "FREE") { + console.log("➡️ 무료 회원 - TempRoutineRecommendScreen으로 이동"); + navigation.navigate("TempRoutineRecommendScreen"); + } else { + console.log("➡️ 프리미엄 회원 - RoutineRecommendNew로 이동"); + navigation.navigate("RoutineRecommendNew"); + } + } catch (error: any) { + console.error("❌ 프로필 조회 실패:", error); + Alert.alert("오류", "사용자 정보를 불러오는데 실패했습니다.", [ + { + text: "확인", + onPress: () => { + // 실패 시 기본적으로 무료 버전으로 이동 + navigation.navigate("TempRoutineRecommendScreen"); + }, + }, + ]); + } finally { + setLoading(false); + } + }; + const handleBulkDelete = async () => { + if (selectedPlanIds.length === 0) { + Alert.alert("알림", "삭제할 운동 플랜을 선택해주세요."); + return; + } + + Alert.alert( + "일괄 삭제", + `선택한 ${selectedPlanIds.length}개의 운동 플랜을 삭제하시겠습니까?`, + [ + { text: "취소", style: "cancel" }, + { + text: "삭제", + style: "destructive", + onPress: async () => { + try { + setLoading(true); + + // TODO: 백엔드 일괄 삭제 API 추가되면 여기서 호출 + + setPlans((prev) => + prev.filter((p) => !selectedPlanIds.includes(p.planId)) + ); + + setSelectedPlanIds([]); + setIsEditMode(false); + + Alert.alert( + "성공", + `${selectedPlanIds.length}개의 운동 플랜이 삭제되었습니다.` + ); + } catch (error: any) { + console.error("일괄 삭제 실패:", error); + Alert.alert("오류", error.message || "삭제에 실패했습니다."); + } finally { + setLoading(false); + } + }, + }, + ] + ); + }; + + const currentDayExercises = selectedPlan?.days?.[selectedDay] || []; + + return ( + + + navigation.goBack()}> + + + + {selectedPlan ? "운동 플랜 상세보기" : "운동 추천 내역"} + + {!selectedPlan && plans.length > 0 && ( + { + if (isEditMode) { + setIsEditMode(false); + setSelectedPlanIds([]); + } else { + setIsEditMode(true); + } + }} + > + {isEditMode ? "완료" : "편집"} + + )} + {!plans.length && } + + + {loading && ( + + + 불러오는 중... + + )} + + + {!selectedPlan ? ( + plans.length === 0 ? ( + + + 추천받은 운동이 없습니다. + 운동 추천을 받아보세요! + navigation.navigate("RoutineRecommendNew")} + > + + 추천받으러 가기 → + + + + ) : ( + + {isEditMode && ( + + + + + 전체 선택 ({selectedPlanIds.length}/{plans.length}) + + + + + 삭제 + + + )} + + {!isEditMode && ( + + {loading ? ( + + ) : ( + + 새 운동 추천받기 + + )} + + )} + + {plans.map((plan) => ( + handlePlanClick(plan)} + activeOpacity={0.98} + > + {isEditMode && ( + + + + )} + + + + + {plan.isServerPlan ? "☁️" : "📱"} + + + {plan.planName} + + {new Date(plan.createdAt).toLocaleDateString("ko-KR")} + + + + {!isEditMode && ( + { + e.stopPropagation(); + handleDelete(plan); + }} + style={styles.deleteBtn} + > + + + )} + + + + {plan.description && ( + + {plan.description} + + )} + + + + + 📅 {plan.days.length}일 운동 + + + + + 💪{" "} + {plan.days.reduce((sum, day) => sum + day.length, 0)} + 개 운동 + + + {plan.isServerPlan && ( + + ☁️ 서버 + + )} + + + + {!isEditMode && ( + + 자세히 보기 → + + )} + + ))} + + ) + ) : ( + + + ← 목록으로 + + + + + + + {selectedPlan.planName} + + + {new Date(selectedPlan.createdAt).toLocaleDateString( + "ko-KR" + )} + + + {selectedPlan.isServerPlan && ( + + ☁️ 서버 + + )} + + + {selectedPlan.description && ( + + {selectedPlan.description} + + )} + + + {/* ✅ days 길이만큼만 탭 표시 (1일~7일 동적) */} + {selectedPlan.days && selectedPlan.days.length > 0 && ( + <> + + {selectedPlan.days.map((_: any, index: number) => ( + setSelectedDay(index)} + > + + {index + 1}일차 + + + ))} + + + + {currentDayExercises.length === 0 ? ( + + + {selectedDay + 1}일차에 운동이 없습니다 + + + ) : ( + currentDayExercises.map( + (exercise: Exercise, index: number) => ( + + + 💪 + + + + {exercise.name} + + {exercise.target && ( + + + + {exercise.target} + + + + )} + {(exercise.sets || + exercise.reps || + exercise.weight) && ( + + {exercise.sets && `${exercise.sets}세트`} + {exercise.reps && ` × ${exercise.reps}회`} + {exercise.weight && ` × ${exercise.weight}kg`} + + )} + + + ) + ) + )} + + + )} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: "#1a1a1a" }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 16, + paddingHorizontal: 20, + borderBottomWidth: 1, + borderBottomColor: "#333333", + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + flex: 1, + textAlign: "center", + }, + editBtn: { fontSize: 16, fontWeight: "600", color: "#e3ff7c" }, + loadingOverlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.7)", + justifyContent: "center", + alignItems: "center", + zIndex: 999, + }, + loadingText: { + color: "#ffffff", + fontSize: 16, + marginTop: 12, + fontWeight: "600", + }, + content: { flex: 1, padding: 20 }, + emptyState: { + alignItems: "center", + paddingVertical: 60, + paddingHorizontal: 20, + }, + emptyText: { + fontSize: 16, + color: "#999999", + textAlign: "center", + marginTop: 20, + marginBottom: 10, + }, + emptySubtitle: { + fontSize: 14, + color: "#666666", + textAlign: "center", + marginBottom: 24, + }, + goToRecommendBtn: { + backgroundColor: "#e3ff7c", + paddingVertical: 12, + paddingHorizontal: 24, + borderRadius: 12, + marginTop: 16, + }, + goToRecommendBtnText: { fontSize: 15, fontWeight: "600", color: "#111111" }, + list: { gap: 16 }, + editToolbar: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#222222", + padding: 16, + borderRadius: 12, + marginBottom: 12, + }, + selectAllBtn: { flexDirection: "row", alignItems: "center", gap: 8 }, + selectAllText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, + bulkDeleteBtn: { + flexDirection: "row", + alignItems: "center", + gap: 6, + backgroundColor: "#ef4444", + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + bulkDeleteBtnDisabled: { backgroundColor: "#666666", opacity: 0.5 }, + bulkDeleteText: { fontSize: 14, fontWeight: "600", color: "#ffffff" }, + newRecommendBtn: { + backgroundColor: "#e3ff7c", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignSelf: "flex-start", + marginBottom: 8, + }, + newRecommendBtnText: { fontSize: 14, fontWeight: "600", color: "#111111" }, + card: { + backgroundColor: "#222222", + borderRadius: 12, + padding: 16, + borderWidth: 2, + borderColor: "transparent", + }, + cardSelected: { borderColor: "#e3ff7c", backgroundColor: "#2a2a1a" }, + checkbox: { position: "absolute", top: 12, left: 12, zIndex: 10 }, + cardHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 12, + }, + dateContainer: { + flexDirection: "row", + alignItems: "center", + gap: 12, + flex: 1, + }, + dateIcon: { fontSize: 24 }, + planName: { + fontSize: 16, + fontWeight: "700", + color: "#ffffff", + marginBottom: 2, + }, + date: { fontSize: 13, fontWeight: "500", color: "#999999" }, + deleteBtn: { padding: 4 }, + cardBody: { marginBottom: 12, gap: 8 }, + description: { fontSize: 14, color: "#cccccc", lineHeight: 20 }, + summary: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + badge: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 12, + backgroundColor: "#4a90e2", + }, + exerciseCountBadge: { backgroundColor: "#e3ff7c" }, + serverBadge: { backgroundColor: "#8b5cf6" }, + badgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, + exerciseCountBadgeText: { fontSize: 12, fontWeight: "500", color: "#111111" }, + serverBadgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, + cardFooter: { alignItems: "flex-end" }, + viewDetail: { fontSize: 14, color: "#e3ff7c", fontWeight: "500" }, + detail: { gap: 20 }, + backBtn: { + backgroundColor: "#2a2a2a", + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + alignSelf: "flex-start", + }, + backBtnText: { fontSize: 14, fontWeight: "500", color: "#ffffff" }, + detailInfo: { backgroundColor: "#222222", padding: 16, borderRadius: 12 }, + detailHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: 8, + }, + detailPlanName: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + }, + detailDate: { fontSize: 14, fontWeight: "500", color: "#999999" }, + detailDescription: { + fontSize: 14, + color: "#cccccc", + lineHeight: 20, + marginTop: 8, + }, + dayTabsContainer: { marginVertical: 8 }, + dayTabs: { gap: 8, paddingBottom: 8 }, + dayTab: { + paddingVertical: 8, + paddingHorizontal: 16, + backgroundColor: "#222222", + borderRadius: 20, + }, + dayTabActive: { backgroundColor: "#e3ff7c" }, + dayTabText: { fontSize: 13, fontWeight: "500", color: "#999999" }, + dayTabTextActive: { color: "#111111", fontWeight: "600" }, + exerciseList: { gap: 12 }, + emptyExercise: { + backgroundColor: "#222222", + padding: 40, + borderRadius: 12, + alignItems: "center", + }, + emptyExerciseText: { fontSize: 14, color: "#999999" }, + exerciseItem: { + backgroundColor: "#2a2a2a", + borderRadius: 12, + padding: 16, + flexDirection: "row", + alignItems: "center", + gap: 15, + }, + exerciseIcon: { + width: 50, + height: 50, + borderRadius: 10, + backgroundColor: "#333333", + justifyContent: "center", + alignItems: "center", + }, + exerciseIconText: { fontSize: 32 }, + exerciseInfo: { flex: 1, gap: 6 }, + exerciseName: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + }, + targetBadgeContainer: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + targetBadgeSmall: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 12, + backgroundColor: "#4a90e2", + }, + targetBadgeSmallText: { + fontSize: 12, + fontWeight: "500", + color: "#ffffff", + }, + exerciseDetail: { + fontSize: 14, + color: "#aaaaaa", + }, +}); + +export default RoutineRecommendScreen; diff --git a/src/screens/exercise/TempRoutineRecommendScreen.tsx b/src/screens/exercise/TempRoutineRecommendScreen.tsx index b218151..baf5f57 100644 --- a/src/screens/exercise/TempRoutineRecommendScreen.tsx +++ b/src/screens/exercise/TempRoutineRecommendScreen.tsx @@ -1,1812 +1,1803 @@ -// src/screens/exercise/TempRoutineRecommendScreen.tsx - -import React, { useState, useEffect, useRef } from "react"; -import { - View, - Text, - StyleSheet, - ScrollView, - TouchableOpacity, - Alert, - Modal, - ActivityIndicator, - Animated, - Easing, - Dimensions, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { Ionicons as Icon } from "@expo/vector-icons"; -import { recommendedExerciseAPI } from "../../services"; -import { LinearGradient } from "expo-linear-gradient"; - -const { width } = Dimensions.get("window"); - -const LOADING_MESSAGES = [ - "입력하신 신체 정보를 분석하는 중...", - "회원님께 최적화된 루틴을 구성하는 중...", - "부위별 밸런스를 계산하는 중...", - "가장 효과적인 운동 조합을 찾는 중...", - "거의 다 됐어요! 득근할 준비 되셨나요?", -]; - -// ✅ LoadingStyles 정의 -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 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 getExerciseIcon = (category: string, muscleName: string) => { - if (category === "CARDIO") return "🏃"; - - const muscleIcons: { [key: string]: string } = { - 하체: "🦵", - LEGS: "🦵", - 가슴: "💪", - CHEST: "💪", - 등: "🏋️", - BACK: "🏋️", - 어깨: "💪", - SHOULDERS: "💪", - 팔: "💪", - ARMS: "💪", - 복근: "🔥", - CORE: "🔥", - ABS: "🔥", - }; - - return muscleIcons[muscleName] || "💪"; -}; - -const mapFocusToKorean = (focus: string): string => { - const focusMap: { [key: string]: string } = { - Upper: "상체", - Core: "코어", - Lower: "하체", - Arms: "팔", - Legs: "하체", - Chest: "가슴", - Back: "등", - Shoulders: "어깨", - "Full Body": "전신", - Cardio: "유산소", - }; - return focusMap[focus] || focus; -}; - -const transformDailyExerciseToUI = (apiResponse: any) => { - console.log("\n=== 📥 일일 운동 추천 API 응답 분석 (Temp) ==="); - const exercises = (apiResponse.exercises || []).map((item: any) => { - let icon = getExerciseIcon("RESISTANCE", item.target || ""); - - const details = []; - if (item.sets) details.push(`${item.sets}세트`); - if (item.reps) details.push(`${item.reps}회`); - if (item.weight_kg) details.push(`${item.weight_kg}kg`); - - const detail = details.join(" × "); - - return { - name: item.name || "운동", - detail: detail || `${item.intensity || "중간"} 강도`, - icon: icon, - exerciseId: item.exerciseId, - target: item.target, - sets: item.sets, - reps: item.reps, - weight_kg: item.weight_kg, - category: item.category, - }; - }); - return exercises; -}; - -const TempRoutineRecommendScreen = ({ navigation }: any) => { - const [showRoutine, setShowRoutine] = useState(false); - const [showWeakPanel, setShowWeakPanel] = useState(false); - const [showLevelPanel, setShowLevelPanel] = useState(false); - const [showTargetPanel, setShowTargetPanel] = useState(false); - const [showEquipmentPanel, setShowEquipmentPanel] = useState(false); - - const [weakParts, setWeakParts] = useState([]); - const [level, setLevel] = useState(""); - const [targetParts, setTargetParts] = useState([]); - const [equipment, setEquipment] = useState(["덤벨"]); - - const [loading, setLoading] = useState(false); - - const [todayRoutine, setTodayRoutine] = useState([]); - const [todayFocus, setTodayFocus] = useState(""); - - // ✅ TempMealRecommendScreen 방식으로 변경: 토큰 부족 상태 관리 - const [isTokenDepleted, setIsTokenDepleted] = useState(false); - - const fadeAnim = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: 1, - duration: 600, - useNativeDriver: true, - }).start(); - }, [showRoutine]); - - const bodyParts = ["목", "어깨", "팔꿈치", "손목", "허리", "무릎", "발목"]; - const targetAreas = ["가슴", "등", "배", "어깨", "팔", "하체"]; - const levels = ["초급", "중급", "고급"]; - const equipmentOptions = [ - "덤벨", - "머신", - "케이블", - "밴드", - "볼", - "바벨", - "케틀벨", - "로프", - ]; - - const handleWeakPartToggle = (part: string) => { - if (weakParts.includes(part)) - setWeakParts(weakParts.filter((p) => p !== part)); - else setWeakParts([...weakParts, part]); - }; - - const handleTargetPartToggle = (part: string) => { - if (targetParts.includes(part)) - setTargetParts(targetParts.filter((p) => p !== part)); - else setTargetParts([...targetParts, part]); - }; - - const handleEquipmentToggle = (item: string) => { - if (equipment.includes(item)) { - if (equipment.length === 1) { - Alert.alert("알림", "최소 1개의 장비를 선택해야 합니다."); - return; - } - setEquipment(equipment.filter((e) => e !== item)); - } else { - setEquipment([...equipment, item]); - } - }; - - const handleCancelLoading = () => { - Alert.alert("요청 취소", "운동 루틴 생성을 취소하시겠습니까?", [ - { text: "계속 기다리기", style: "cancel" }, - { - text: "취소", - style: "destructive", - onPress: () => setLoading(false), - }, - ]); - }; - - const mapLevelToAPI = (level: string): string => { - const levelMap: { [key: string]: string } = { - 초급: "BEGINNER", - 중급: "INTERMEDIATE", - 고급: "ADVANCED", - }; - return levelMap[level] || "INTERMEDIATE"; - }; - - const handleGetRoutine = async () => { - // ✅ 토큰 소진 상태일 때 차단 - if (isTokenDepleted) { - Alert.alert("알림", "이미 이번 주 추천 횟수를 모두 사용했습니다."); - return; - } - - setLoading(true); - - try { - console.log("🏋️ 운동 루틴 추천 시작 (무료)"); - - // 프로필 확인 - let userProfile; - try { - userProfile = await recommendedExerciseAPI.getProfile(); - } catch (error: any) { - if (error.status === 401) { - Alert.alert("로그인 필요", "로그인이 만료되었습니다.", [ - { text: "확인", onPress: () => navigation.navigate("Login") }, - ]); - setLoading(false); - return; - } - if (error.status === 404) { - Alert.alert("프로필 설정 필요", "먼저 프로필을 설정해주세요."); - setLoading(false); - return; - } - throw error; - } - - if ( - !userProfile?.height || - !userProfile?.weight || - !userProfile?.healthGoal - ) { - Alert.alert( - "프로필 정보 부족", - "키, 몸무게, 운동 목표를 입력해주세요." - ); - setLoading(false); - return; - } - - const requestBody = { - experienceLevel: level ? mapLevelToAPI(level) : "INTERMEDIATE", - environment: "gym", - availableEquipment: equipment, - likeMuscles: targetParts.length > 0 ? targetParts : [], - healthConditions: weakParts.length > 0 ? weakParts : [], - targetTimeMin: 40, - }; - - console.log( - "📤 운동 플랜 생성 요청 body:", - JSON.stringify(requestBody, null, 2) - ); - - const today = new Date().toISOString().split("T")[0]; - const apiResponse = await recommendedExerciseAPI.generateExercisePlan( - today, - requestBody - ); - - if (apiResponse && apiResponse.success && apiResponse.exercises) { - if (apiResponse.focus) { - setTodayFocus(apiResponse.focus); - } - - const todayExercises = transformDailyExerciseToUI(apiResponse); - setTodayRoutine(todayExercises); - setShowRoutine(true); - - Alert.alert("성공", "운동 루틴이 생성되었습니다!"); - return; - } - - throw new Error(apiResponse?.message || "운동 루틴 생성에 실패했습니다."); - } catch (error: any) { - console.error("❌ 운동 루틴 생성 실패:", error); - - // ✅ TempMealRecommendScreen 방식: 에러 메시지로 토큰 소진 판단 - const errorMessage = error.message || ""; - if ( - errorMessage.includes("토큰이 부족") || - errorMessage.includes("무료 운동 추천") || - error.code === "NO_WORKOUT_TOKENS" || - error.status === 403 - ) { - setIsTokenDepleted(true); - Alert.alert( - "알림", - "이번 주 무료 추천 횟수를 모두 사용했습니다.\n다음 주 월요일에 다시 시도해주세요." - ); - } else { - Alert.alert("오류", errorMessage || "운동 루틴 생성에 실패했습니다."); - } - } finally { - setLoading(false); - } - }; - - const handleSaveRoutine = async () => { - const currentDate = new Date(); - const dateStr = `${currentDate.getFullYear()}-${String( - currentDate.getMonth() + 1 - ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; - - const groupKey = `ai_recommend_free_${Date.now()}`; - const groupTitle = todayFocus - ? `AI 추천 (무료) - ${mapFocusToKorean(todayFocus)}` - : "AI 추천 (무료)"; - - const activities = todayRoutine.map((exercise: any, index: number) => { - const setsCount = exercise.sets || 3; - const sets = Array.from({ length: setsCount }, (_, i) => ({ - id: i + 1, - order: i + 1, - weight: exercise.weight_kg || 0, - reps: exercise.reps || 0, - isCompleted: false, - })); - - return { - id: Date.now() + index, - name: exercise.name || "운동", - details: `${exercise.weight_kg || 0}kg ${ - exercise.reps || 0 - }회 ${setsCount}세트`, - time: currentDate.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - hour12: true, - }), - date: dateStr, - isCompleted: false, - externalId: exercise.exerciseId, - targetMuscle: exercise.target, - sets: sets, - saveTitle: groupTitle, - groupKey: groupKey, - }; - }); - - console.log("🚀 ExerciseScreen으로 이동:", { - activitiesCount: activities.length, - }); - - navigation.navigate("Stats", { - screen: "Exercise", - params: { - recommendedExercises: activities, - }, - }); - }; - - const handleRecommendAgain = () => { - setShowRoutine(false); - setTodayRoutine([]); - setTodayFocus(""); - }; - - // ✅ 버튼 비활성화 조건: 로딩 중이거나 토큰 소진 - const isDisabled = loading || isTokenDepleted; - - return ( - - - - - - - - navigation.goBack()} - style={styles.backButton} - > - - - - - 운동 루틴 - - - - {!showRoutine ? ( - - - - - 💪 - - - - 오늘의 무료 루틴 - - 매주 1회 제공되는 무료 AI 추천 루틴입니다.{"\n"}더 많은 추천은 - 프리미엄을 이용해주세요! - - - - - - - - 무료 체험 - - - - - 일주일에 단 한 번만 추천 가능! - - - - navigation.navigate("Main", { - screen: "MyPage", - params: { openPremiumModal: true }, - } as any) - } - activeOpacity={0.9} - > - - - - 프리미엄으로 무제한 추천받기 - - - - - - - - - - {isDisabled ? ( - - - - 이번 주 추천 완료 - - - ) : ( - - - - 무료 루틴 받기 - - - )} - - - {isTokenDepleted && ( - - - - 이번 주 무료 추천을 모두 사용하였습니다.{"\n"} - 다음 주 월요일에 다시 추천 받을 수 있습니다. - - - )} - - - setShowEquipmentPanel(true)} - activeOpacity={0.8} - > - - - - - - - - - 사용가능한 장비 - - - - - - {equipment.length > 0 && ( - - {equipment.map((item, index) => ( - - - {item} - - - ))} - - )} - - - - setShowWeakPanel(true)} - activeOpacity={0.8} - > - - - - - - - - 취약한 부분 - - - - - {weakParts.length > 0 && ( - - {weakParts.map((part, index) => ( - - - {part} - - - ))} - - )} - - - - setShowLevelPanel(true)} - activeOpacity={0.8} - > - - - - - - - - 운동 경력 - - - - - {level && ( - - - - {level} - - - - )} - - - - setShowTargetPanel(true)} - activeOpacity={0.8} - > - - - - - - - - 집중 부위 - - - - - {targetParts.length > 0 && ( - - {targetParts.map((part, index) => ( - - - {part} - - - ))} - - )} - - - - - ) : ( - - - - - - 오늘의 무료 루틴 - {todayFocus ? ` - ${mapFocusToKorean(todayFocus)}` : ""} - - - {new Date().toLocaleDateString("ko-KR", { - month: "long", - day: "numeric", - weekday: "short", - })} - - - - - - {todayRoutine.map((exercise, index) => ( - - - - - - {exercise.icon} - - - - - {exercise.name} - - {exercise.detail} - - - - - {index + 1} - - - - - ))} - - - - - - - 루틴 저장하기 - - - - {/* 다시 추천받기 (무료는 제한이 있으므로 UI 리셋 용도) */} - - - - 다시 선택하기 - - - - - - )} - - {/* Modal Components */} - setShowEquipmentPanel(false)} - > - setShowEquipmentPanel(false)} - > - - - - 사용가능한 장비 선택 - - - {equipmentOptions.map((item) => ( - handleEquipmentToggle(item)} - activeOpacity={0.8} - > - {equipment.includes(item) ? ( - - - - {item} - - - ) : ( - - {item} - - )} - - ))} - - setShowEquipmentPanel(false)} - activeOpacity={0.9} - > - - 선택 완료 - - - - - - - - - setShowWeakPanel(false)} - > - setShowWeakPanel(false)} - > - - - - 취약한 부분 선택 - - - {bodyParts.map((part) => ( - handleWeakPartToggle(part)} - activeOpacity={0.8} - > - {weakParts.includes(part) ? ( - - - - {part} - - - ) : ( - - {part} - - )} - - ))} - - setShowWeakPanel(false)} - activeOpacity={0.9} - > - - 선택 완료 - - - - - - - - - setShowLevelPanel(false)} - > - setShowLevelPanel(false)} - > - - - - 운동 경력 선택 - - - {levels.map((lv) => ( - setLevel(lv)} - activeOpacity={0.8} - > - {level === lv ? ( - - - - {lv} - - - ) : ( - - {lv} - - )} - - ))} - - setShowLevelPanel(false)} - activeOpacity={0.9} - > - - 선택 완료 - - - - - - - - - setShowTargetPanel(false)} - > - setShowTargetPanel(false)} - > - - - - 보강하고 싶은 부위 - - - {targetAreas.map((area) => ( - handleTargetPartToggle(area)} - activeOpacity={0.8} - > - {targetParts.includes(area) ? ( - - - - {area} - - - ) : ( - - {area} - - )} - - ))} - - setShowTargetPanel(false)} - activeOpacity={0.9} - > - - 선택 완료 - - - - - - - - - - ); -}; - -// ✅ 스타일은 동일하게 유지 -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - safeArea: { - flex: 1, - }, - 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, - }, - content: { - flex: 1, - }, - contentContainer: { - paddingHorizontal: 20, - paddingTop: 40, - paddingBottom: 40, - }, - mainContent: { - alignItems: "center", - width: "100%", - }, - 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, - }, - title: { - fontSize: 32, - fontWeight: "bold", - color: "#ffffff", - marginBottom: 12, - textAlign: "center", - letterSpacing: 0.5, - }, - subtitle: { - fontSize: 16, - color: "#9ca3af", - textAlign: "center", - lineHeight: 24, - marginBottom: 40, - letterSpacing: 0.3, - }, - - // 무료 회원 배너 - freeUserBannerContainer: { - width: "100%", - 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", - letterSpacing: 0.5, - }, - freeUserTitle: { - fontSize: 20, - fontWeight: "700", - color: "#e3ff7c", - letterSpacing: 0.3, - }, - remainingCount: { - alignSelf: "flex-start", - borderRadius: 12, - overflow: "hidden", - backgroundColor: "rgba(255,255,255,0.05)", - }, - remainingCountInner: { - flexDirection: "row", - alignItems: "center", - gap: 8, - paddingHorizontal: 12, - paddingVertical: 8, - }, - remainingText: { - fontSize: 14, - color: "#ffffff", - fontWeight: "500", - }, - remainingNumber: { - fontSize: 15, - fontWeight: "700", - color: "#e3ff7c", - }, - 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", - }, - - // 버튼 그룹 - buttonGroup: { - width: "100%", - gap: 16, - }, - 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, - }, - primaryButtonTextDisabled: { - fontSize: 16, - fontWeight: "600", - color: "#6b7280", - letterSpacing: 0.3, - }, - disabledButtonShadow: { - shadowOpacity: 0, - elevation: 0, - shadowColor: "transparent", - }, - - // 사용 완료 안내 - usedNotice: { - alignItems: "center", - justifyContent: "center", - marginBottom: 12, - padding: 12, - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 12, - }, - usedNoticeText: { - fontSize: 13, - color: "#9ca3af", - fontWeight: "500", - textAlign: "center", - lineHeight: 20, - }, - - // 옵션 카드 - optionCard: { - width: "100%", - }, - optionButton: { - borderRadius: 14, - overflow: "hidden", - }, - optionButtonGradient: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: 16, - paddingHorizontal: 16, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 14, - }, - optionButtonLeft: { - flexDirection: "row", - alignItems: "center", - gap: 12, - }, - optionIconContainer: { - width: 40, - height: 40, - borderRadius: 20, - overflow: "hidden", - }, - optionIcon: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - optionButtonText: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - letterSpacing: 0.3, - }, - selectedTags: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - marginTop: 12, - paddingHorizontal: 4, - }, - tag: { - borderRadius: 12, - overflow: "hidden", - }, - tagGradient: { - paddingVertical: 6, - paddingHorizontal: 12, - borderRadius: 12, - }, - tagText: { - fontSize: 13, - color: "#ffffff", - fontWeight: "500", - letterSpacing: 0.3, - }, - - // 루틴 뷰 - routineView: { - padding: 20, - }, - routineHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - marginBottom: 24, - }, - routineTitle: { - fontSize: 26, - fontWeight: "700", - color: "#ffffff", - marginBottom: 6, - letterSpacing: 0.5, - }, - routineDate: { - fontSize: 14, - color: "#6b7280", - letterSpacing: 0.3, - }, - exerciseList: { - gap: 12, - marginBottom: 24, - }, - exerciseCardWrapper: { - borderRadius: 14, - overflow: "hidden", - }, - exerciseCard: { - flexDirection: "row", - alignItems: "center", - padding: 16, - gap: 14, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - borderRadius: 14, - }, - exerciseIconContainer: { - width: 56, - height: 56, - borderRadius: 14, - overflow: "hidden", - }, - exerciseIconGradient: { - width: "100%", - height: "100%", - alignItems: "center", - justifyContent: "center", - }, - exerciseIconText: { - fontSize: 28, - }, - exerciseInfo: { - flex: 1, - }, - exerciseName: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - marginBottom: 6, - letterSpacing: 0.3, - }, - exerciseDetail: { - fontSize: 13, - color: "#9ca3af", - letterSpacing: 0.2, - }, - exerciseNumber: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: "rgba(227,255,124,0.1)", - alignItems: "center", - justifyContent: "center", - }, - exerciseNumberText: { - fontSize: 12, - fontWeight: "700", - color: "#e3ff7c", - }, - routineButtons: { - 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, - }, - - // 모달 스타일 - panelOverlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.7)", - justifyContent: "flex-end", - }, - bottomPanel: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: "hidden", - maxHeight: "80%", - }, - bottomPanelGradient: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - paddingBottom: 30, - }, - panelHandle: { - width: 40, - height: 4, - backgroundColor: "#555555", - borderRadius: 2, - alignSelf: "center", - marginTop: 12, - marginBottom: 20, - }, - panelHeaderText: { - fontSize: 20, - fontWeight: "700", - color: "#ffffff", - marginBottom: 20, - paddingHorizontal: 20, - letterSpacing: 0.5, - }, - panelBody: { - paddingHorizontal: 20, - }, - optionGrid: { - flexDirection: "row", - flexWrap: "wrap", - gap: 12, - marginBottom: 24, - }, - modalOptionWrapper: { - width: "31%", - borderRadius: 14, - overflow: "hidden", - }, - modalOption: { - height: 52, - backgroundColor: "rgba(255,255,255,0.05)", - borderRadius: 14, - borderWidth: 1, - borderColor: "rgba(255,255,255,0.1)", - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 6, - }, - modalOptionSelected: { - height: 52, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 4, - borderRadius: 14, - paddingHorizontal: 6, - }, - modalOptionText: { - fontSize: 14, - fontWeight: "500", - color: "#ffffff", - letterSpacing: 0.3, - textAlign: "center", - }, - modalOptionTextSelected: { - fontSize: 14, - color: "#111827", - fontWeight: "700", - letterSpacing: 0.3, - textAlign: "center", - }, - confirmButton: { - borderRadius: 16, - overflow: "hidden", - shadowColor: "#e3ff7c", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 8, - }, - confirmButtonGradient: { - paddingVertical: 18, - alignItems: "center", - justifyContent: "center", - }, - confirmButtonText: { - fontSize: 17, - fontWeight: "700", - color: "#111827", - letterSpacing: 0.5, - }, -}); - -export default TempRoutineRecommendScreen; +// src/screens/exercise/TempRoutineRecommendScreen.tsx + +import React, { useState, useEffect, useRef } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Alert, + Modal, + ActivityIndicator, + Animated, + Easing, + Dimensions, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Ionicons as Icon } from "@expo/vector-icons"; +import { recommendedExerciseAPI } from "../../services"; +import { LinearGradient } from "expo-linear-gradient"; + +const { width } = Dimensions.get("window"); + +const LOADING_MESSAGES = [ + "입력하신 신체 정보를 분석하는 중...", + "회원님께 최적화된 루틴을 구성하는 중...", + "부위별 밸런스를 계산하는 중...", + "가장 효과적인 운동 조합을 찾는 중...", + "거의 다 됐어요! 득근할 준비 되셨나요?", +]; + +// ✅ LoadingStyles 정의 +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 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 getExerciseIcon = (category: string, muscleName: string) => { + if (category === "CARDIO") return "🏃"; + + const muscleIcons: { [key: string]: string } = { + 하체: "🦵", + LEGS: "🦵", + 가슴: "💪", + CHEST: "💪", + 등: "🏋️", + BACK: "🏋️", + 어깨: "💪", + SHOULDERS: "💪", + 팔: "💪", + ARMS: "💪", + 복근: "🔥", + CORE: "🔥", + ABS: "🔥", + }; + + return muscleIcons[muscleName] || "💪"; +}; + +const mapFocusToKorean = (focus: string): string => { + const focusMap: { [key: string]: string } = { + Upper: "상체", + Core: "코어", + Lower: "하체", + Arms: "팔", + Legs: "하체", + Chest: "가슴", + Back: "등", + Shoulders: "어깨", + "Full Body": "전신", + Cardio: "유산소", + }; + return focusMap[focus] || focus; +}; + +const transformDailyExerciseToUI = (apiResponse: any) => { + console.log("\n=== 📥 일일 운동 추천 API 응답 분석 (Temp) ==="); + const exercises = (apiResponse.exercises || []).map((item: any) => { + let icon = getExerciseIcon("RESISTANCE", item.target || ""); + + const details = []; + if (item.sets) details.push(`${item.sets}세트`); + if (item.reps) details.push(`${item.reps}회`); + if (item.weight_kg) details.push(`${item.weight_kg}kg`); + + const detail = details.join(" × "); + + return { + name: item.name || "운동", + detail: detail || `${item.intensity || "중간"} 강도`, + icon: icon, + exerciseId: item.exerciseId, + target: item.target, + sets: item.sets, + reps: item.reps, + weight_kg: item.weight_kg, + category: item.category, + }; + }); + return exercises; +}; + +const TempRoutineRecommendScreen = ({ navigation }: any) => { + const [showRoutine, setShowRoutine] = useState(false); + const [showWeakPanel, setShowWeakPanel] = useState(false); + const [showLevelPanel, setShowLevelPanel] = useState(false); + const [showTargetPanel, setShowTargetPanel] = useState(false); + const [showEquipmentPanel, setShowEquipmentPanel] = useState(false); + + const [weakParts, setWeakParts] = useState([]); + const [level, setLevel] = useState(""); + const [targetParts, setTargetParts] = useState([]); + const [equipment, setEquipment] = useState(["덤벨"]); + + const [loading, setLoading] = useState(false); + + const [todayRoutine, setTodayRoutine] = useState([]); + const [todayFocus, setTodayFocus] = useState(""); + + // ✅ TempMealRecommendScreen 방식으로 변경: 토큰 부족 상태 관리 + const [isTokenDepleted, setIsTokenDepleted] = useState(false); + + const fadeAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }).start(); + }, [showRoutine]); + + const bodyParts = ["목", "어깨", "팔꿈치", "손목", "허리", "무릎", "발목"]; + const targetAreas = ["가슴", "등", "배", "어깨", "팔", "하체"]; + const levels = ["초급", "중급", "고급"]; + const equipmentOptions = [ + "덤벨", + "머신", + "케이블", + "밴드", + "볼", + "바벨", + "케틀벨", + "로프", + ]; + + const handleWeakPartToggle = (part: string) => { + if (weakParts.includes(part)) + setWeakParts(weakParts.filter((p) => p !== part)); + else setWeakParts([...weakParts, part]); + }; + + const handleTargetPartToggle = (part: string) => { + if (targetParts.includes(part)) + setTargetParts(targetParts.filter((p) => p !== part)); + else setTargetParts([...targetParts, part]); + }; + + const handleEquipmentToggle = (item: string) => { + if (equipment.includes(item)) { + if (equipment.length === 1) { + Alert.alert("알림", "최소 1개의 장비를 선택해야 합니다."); + return; + } + setEquipment(equipment.filter((e) => e !== item)); + } else { + setEquipment([...equipment, item]); + } + }; + + const handleCancelLoading = () => { + Alert.alert("요청 취소", "운동 루틴 생성을 취소하시겠습니까?", [ + { text: "계속 기다리기", style: "cancel" }, + { + text: "취소", + style: "destructive", + onPress: () => setLoading(false), + }, + ]); + }; + + const mapLevelToAPI = (level: string): string => { + const levelMap: { [key: string]: string } = { + 초급: "BEGINNER", + 중급: "INTERMEDIATE", + 고급: "ADVANCED", + }; + return levelMap[level] || "INTERMEDIATE"; + }; + + const handleGetRoutine = async () => { + // ✅ 토큰 소진 상태일 때 차단 + if (isTokenDepleted) { + Alert.alert("알림", "이미 이번 주 추천 횟수를 모두 사용했습니다."); + return; + } + + setLoading(true); + + try { + console.log("🏋️ 운동 루틴 추천 시작 (무료)"); + + // 프로필 확인 + let userProfile; + try { + userProfile = await recommendedExerciseAPI.getProfile(); + } catch (error: any) { + if (error.status === 401) { + Alert.alert("로그인 필요", "로그인이 만료되었습니다.", [ + { text: "확인", onPress: () => navigation.navigate("Login") }, + ]); + setLoading(false); + return; + } + if (error.status === 404) { + Alert.alert("프로필 설정 필요", "먼저 프로필을 설정해주세요."); + setLoading(false); + return; + } + throw error; + } + + if ( + !userProfile?.height || + !userProfile?.weight || + !userProfile?.healthGoal + ) { + Alert.alert( + "프로필 정보 부족", + "키, 몸무게, 운동 목표를 입력해주세요." + ); + setLoading(false); + return; + } + + const requestBody = { + experienceLevel: level ? mapLevelToAPI(level) : "INTERMEDIATE", + environment: "gym", + availableEquipment: equipment, + likeMuscles: targetParts.length > 0 ? targetParts : [], + healthConditions: weakParts.length > 0 ? weakParts : [], + targetTimeMin: 40, + }; + + console.log( + "📤 운동 플랜 생성 요청 body:", + JSON.stringify(requestBody, null, 2) + ); + + const today = new Date().toISOString().split("T")[0]; + const apiResponse = await recommendedExerciseAPI.generateExercisePlan( + today, + requestBody + ); + + if (apiResponse && apiResponse.success && apiResponse.exercises) { + if (apiResponse.focus) { + setTodayFocus(apiResponse.focus); + } + + const todayExercises = transformDailyExerciseToUI(apiResponse); + setTodayRoutine(todayExercises); + setShowRoutine(true); + + Alert.alert("성공", "운동 루틴이 생성되었습니다!"); + return; + } + + throw new Error(apiResponse?.message || "운동 루틴 생성에 실패했습니다."); + } catch (error: any) { + console.error("❌ 운동 루틴 생성 실패:", error); + + // ✅ TempMealRecommendScreen 방식: 에러 메시지로 토큰 소진 판단 + const errorMessage = error.message || ""; + if ( + errorMessage.includes("토큰이 부족") || + errorMessage.includes("무료 운동 추천") || + error.code === "NO_WORKOUT_TOKENS" || + error.status === 403 + ) { + setIsTokenDepleted(true); + Alert.alert( + "알림", + "이번 주 무료 추천 횟수를 모두 사용했습니다.\n다음 주 월요일에 다시 시도해주세요." + ); + } else { + Alert.alert("오류", errorMessage || "운동 루틴 생성에 실패했습니다."); + } + } finally { + setLoading(false); + } + }; + + const handleSaveRoutine = async () => { + const currentDate = new Date(); + const dateStr = `${currentDate.getFullYear()}-${String( + currentDate.getMonth() + 1 + ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; + + const groupKey = `ai_recommend_free_${Date.now()}`; + const groupTitle = todayFocus + ? `AI 추천 (무료) - ${mapFocusToKorean(todayFocus)}` + : "AI 추천 (무료)"; + + const activities = todayRoutine.map((exercise: any, index: number) => { + const setsCount = exercise.sets || 3; + const sets = Array.from({ length: setsCount }, (_, i) => ({ + id: i + 1, + order: i + 1, + weight: exercise.weight_kg || 0, + reps: exercise.reps || 0, + isCompleted: false, + })); + + return { + id: Date.now() + index, + name: exercise.name || "운동", + details: `${exercise.weight_kg || 0}kg ${ + exercise.reps || 0 + }회 ${setsCount}세트`, + time: currentDate.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), + date: dateStr, + isCompleted: false, + externalId: exercise.exerciseId, + targetMuscle: exercise.target, + sets: sets, + saveTitle: groupTitle, + groupKey: groupKey, + }; + }); + + console.log("🚀 ExerciseScreen으로 이동:", { + activitiesCount: activities.length, + }); + + navigation.navigate("Stats", { + screen: "Exercise", + params: { + recommendedExercises: activities, + }, + }); + }; + + const handleRecommendAgain = () => { + setShowRoutine(false); + setTodayRoutine([]); + setTodayFocus(""); + }; + + // ✅ 버튼 비활성화 조건: 로딩 중이거나 토큰 소진 + const isDisabled = loading || isTokenDepleted; + + return ( + + + + + + + + navigation.goBack()} + style={styles.backButton} + > + + + + + 운동 루틴 + + + + {!showRoutine ? ( + + + 오늘의 무료 루틴 + + 매주 1회 제공되는 무료 AI 추천 루틴입니다.{"\n"}더 많은 추천은 + 프리미엄을 이용해주세요! + + + + + + + + 무료 체험 + + + + + 일주일에 단 한 번만 추천 가능! + + + + navigation.navigate("Main", { + screen: "MyPage", + params: { openPremiumModal: true }, + } as any) + } + activeOpacity={0.9} + > + + + + 프리미엄으로 무제한 추천받기 + + + + + + + + + + {isDisabled ? ( + + + + 이번 주 추천 완료 + + + ) : ( + + + + 무료 루틴 받기 + + + )} + + + {isTokenDepleted && ( + + + + 이번 주 무료 추천을 모두 사용하였습니다.{"\n"} + 다음 주 월요일에 다시 추천 받을 수 있습니다. + + + )} + + + setShowEquipmentPanel(true)} + activeOpacity={0.8} + > + + + + + + + + + 사용가능한 장비 + + + + + + {equipment.length > 0 && ( + + {equipment.map((item, index) => ( + + + {item} + + + ))} + + )} + + + + setShowWeakPanel(true)} + activeOpacity={0.8} + > + + + + + + + + 취약한 부분 + + + + + {weakParts.length > 0 && ( + + {weakParts.map((part, index) => ( + + + {part} + + + ))} + + )} + + + + setShowLevelPanel(true)} + activeOpacity={0.8} + > + + + + + + + + 운동 경력 + + + + + {level && ( + + + + {level} + + + + )} + + + + setShowTargetPanel(true)} + activeOpacity={0.8} + > + + + + + + + + 집중 부위 + + + + + {targetParts.length > 0 && ( + + {targetParts.map((part, index) => ( + + + {part} + + + ))} + + )} + + + + + ) : ( + + + + + + 오늘의 무료 루틴 + {todayFocus ? ` - ${mapFocusToKorean(todayFocus)}` : ""} + + + {new Date().toLocaleDateString("ko-KR", { + month: "long", + day: "numeric", + weekday: "short", + })} + + + + + + {todayRoutine.map((exercise, index) => ( + + + + + + {exercise.icon} + + + + + {exercise.name} + + {exercise.detail} + + + + + {index + 1} + + + + + ))} + + + + + + + 루틴 저장하기 + + + + {/* 다시 추천받기 (무료는 제한이 있으므로 UI 리셋 용도) */} + + + + 다시 선택하기 + + + + + + )} + + {/* Modal Components */} + setShowEquipmentPanel(false)} + > + setShowEquipmentPanel(false)} + > + + + + 사용가능한 장비 선택 + + + {equipmentOptions.map((item) => ( + handleEquipmentToggle(item)} + activeOpacity={0.8} + > + {equipment.includes(item) ? ( + + + + {item} + + + ) : ( + + {item} + + )} + + ))} + + setShowEquipmentPanel(false)} + activeOpacity={0.9} + > + + 선택 완료 + + + + + + + + + setShowWeakPanel(false)} + > + setShowWeakPanel(false)} + > + + + + 취약한 부분 선택 + + + {bodyParts.map((part) => ( + handleWeakPartToggle(part)} + activeOpacity={0.8} + > + {weakParts.includes(part) ? ( + + + + {part} + + + ) : ( + + {part} + + )} + + ))} + + setShowWeakPanel(false)} + activeOpacity={0.9} + > + + 선택 완료 + + + + + + + + + setShowLevelPanel(false)} + > + setShowLevelPanel(false)} + > + + + + 운동 경력 선택 + + + {levels.map((lv) => ( + setLevel(lv)} + activeOpacity={0.8} + > + {level === lv ? ( + + + + {lv} + + + ) : ( + + {lv} + + )} + + ))} + + setShowLevelPanel(false)} + activeOpacity={0.9} + > + + 선택 완료 + + + + + + + + + setShowTargetPanel(false)} + > + setShowTargetPanel(false)} + > + + + + 보강하고 싶은 부위 + + + {targetAreas.map((area) => ( + handleTargetPartToggle(area)} + activeOpacity={0.8} + > + {targetParts.includes(area) ? ( + + + + {area} + + + ) : ( + + {area} + + )} + + ))} + + setShowTargetPanel(false)} + activeOpacity={0.9} + > + + 선택 완료 + + + + + + + + + + ); +}; + +// ✅ 스타일은 동일하게 유지 +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + safeArea: { + flex: 1, + }, + 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, + }, + content: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: 20, + paddingTop: 40, + paddingBottom: 40, + }, + mainContent: { + alignItems: "center", + width: "100%", + }, + 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, + }, + title: { + fontSize: 32, + fontWeight: "bold", + color: "#ffffff", + marginBottom: 12, + textAlign: "center", + letterSpacing: 0.5, + }, + subtitle: { + fontSize: 16, + color: "#9ca3af", + textAlign: "center", + lineHeight: 24, + marginBottom: 40, + letterSpacing: 0.3, + }, + + // 무료 회원 배너 + freeUserBannerContainer: { + width: "100%", + 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", + letterSpacing: 0.5, + }, + freeUserTitle: { + fontSize: 20, + fontWeight: "700", + color: "#e3ff7c", + letterSpacing: 0.3, + }, + remainingCount: { + alignSelf: "flex-start", + borderRadius: 12, + overflow: "hidden", + backgroundColor: "rgba(255,255,255,0.05)", + }, + remainingCountInner: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + remainingText: { + fontSize: 14, + color: "#ffffff", + fontWeight: "500", + }, + remainingNumber: { + fontSize: 15, + fontWeight: "700", + color: "#e3ff7c", + }, + 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", + }, + + // 버튼 그룹 + buttonGroup: { + width: "100%", + gap: 16, + }, + 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, + }, + primaryButtonTextDisabled: { + fontSize: 16, + fontWeight: "600", + color: "#6b7280", + letterSpacing: 0.3, + }, + disabledButtonShadow: { + shadowOpacity: 0, + elevation: 0, + shadowColor: "transparent", + }, + + // 사용 완료 안내 + usedNotice: { + alignItems: "center", + justifyContent: "center", + marginBottom: 12, + padding: 12, + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 12, + }, + usedNoticeText: { + fontSize: 13, + color: "#9ca3af", + fontWeight: "500", + textAlign: "center", + lineHeight: 20, + }, + + // 옵션 카드 + optionCard: { + width: "100%", + }, + optionButton: { + borderRadius: 14, + overflow: "hidden", + }, + optionButtonGradient: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: 16, + paddingHorizontal: 16, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 14, + }, + optionButtonLeft: { + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + optionIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + overflow: "hidden", + }, + optionIcon: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + optionButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + letterSpacing: 0.3, + }, + selectedTags: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginTop: 12, + paddingHorizontal: 4, + }, + tag: { + borderRadius: 12, + overflow: "hidden", + }, + tagGradient: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 12, + }, + tagText: { + fontSize: 13, + color: "#ffffff", + fontWeight: "500", + letterSpacing: 0.3, + }, + + // 루틴 뷰 + routineView: { + padding: 20, + }, + routineHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 24, + }, + routineTitle: { + fontSize: 26, + fontWeight: "700", + color: "#ffffff", + marginBottom: 6, + letterSpacing: 0.5, + }, + routineDate: { + fontSize: 14, + color: "#6b7280", + letterSpacing: 0.3, + }, + exerciseList: { + gap: 12, + marginBottom: 24, + }, + exerciseCardWrapper: { + borderRadius: 14, + overflow: "hidden", + }, + exerciseCard: { + flexDirection: "row", + alignItems: "center", + padding: 16, + gap: 14, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + borderRadius: 14, + }, + exerciseIconContainer: { + width: 56, + height: 56, + borderRadius: 14, + overflow: "hidden", + }, + exerciseIconGradient: { + width: "100%", + height: "100%", + alignItems: "center", + justifyContent: "center", + }, + exerciseIconText: { + fontSize: 28, + }, + exerciseInfo: { + flex: 1, + }, + exerciseName: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + marginBottom: 6, + letterSpacing: 0.3, + }, + exerciseDetail: { + fontSize: 13, + color: "#9ca3af", + letterSpacing: 0.2, + }, + exerciseNumber: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: "rgba(227,255,124,0.1)", + alignItems: "center", + justifyContent: "center", + }, + exerciseNumberText: { + fontSize: 12, + fontWeight: "700", + color: "#e3ff7c", + }, + routineButtons: { + 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, + }, + + // 모달 스타일 + panelOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "flex-end", + }, + bottomPanel: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + overflow: "hidden", + maxHeight: "80%", + }, + bottomPanelGradient: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingBottom: 30, + }, + panelHandle: { + width: 40, + height: 4, + backgroundColor: "#555555", + borderRadius: 2, + alignSelf: "center", + marginTop: 12, + marginBottom: 20, + }, + panelHeaderText: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 20, + paddingHorizontal: 20, + letterSpacing: 0.5, + }, + panelBody: { + paddingHorizontal: 20, + }, + optionGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 12, + marginBottom: 24, + }, + modalOptionWrapper: { + width: "31%", + borderRadius: 14, + overflow: "hidden", + }, + modalOption: { + height: 52, + backgroundColor: "rgba(255,255,255,0.05)", + borderRadius: 14, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.1)", + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 6, + }, + modalOptionSelected: { + height: 52, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 4, + borderRadius: 14, + paddingHorizontal: 6, + }, + modalOptionText: { + fontSize: 14, + fontWeight: "500", + color: "#ffffff", + letterSpacing: 0.3, + textAlign: "center", + }, + modalOptionTextSelected: { + fontSize: 14, + color: "#111827", + fontWeight: "700", + letterSpacing: 0.3, + textAlign: "center", + }, + confirmButton: { + borderRadius: 16, + overflow: "hidden", + shadowColor: "#e3ff7c", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + confirmButtonGradient: { + paddingVertical: 18, + alignItems: "center", + justifyContent: "center", + }, + confirmButtonText: { + fontSize: 17, + fontWeight: "700", + color: "#111827", + letterSpacing: 0.5, + }, +}); + +export default TempRoutineRecommendScreen; diff --git a/src/screens/main/MyPageScreen.tsx b/src/screens/main/MyPageScreen.tsx index fa01c98..47faac6 100644 --- a/src/screens/main/MyPageScreen.tsx +++ b/src/screens/main/MyPageScreen.tsx @@ -1,731 +1,720 @@ -// src/screens/main/MyPageScreen.tsx - -import React, { useState, useEffect } from "react"; -import { - View, - Text, - TouchableOpacity, - StyleSheet, - ScrollView, - Alert, - ActivityIndicator, -} 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 { authAPI } from "../../services"; -import AIAnalysisModal from "../../components/modals/AIAnalysisModal"; -import MyPlanModal from "../../components/modals/MyPlanModal"; -import PaymentMethodModal from "../../components/modals/PaymentMethodModal"; -import ProfileEditModal from "../../components/modals/ProfileEditModal"; -import RoutineRecommendModal from "../../components/modals/RoutineRecommendModal"; -import MealRecommendModal from "../../components/modals/MealRecommendModal"; -import DeleteAccountModal from "../../components/modals/DeleteAccountModal"; -import PremiumModal from "../../components/modals/PremiumModal"; -import { useRoute } from "@react-navigation/native"; -import { useFocusEffect } from "@react-navigation/native"; -import { getProfile } from "@react-native-seoul/kakao-login"; - -const MyPageScreen = ({ navigation }: any) => { - const route = useRoute(); - - const [profileData, setProfileData] = useState(null); - const [loading, setLoading] = useState(true); - const [currentMembershipType, setCurrentMembershipType] = useState< - "FREE" | "PREMIUM" - >("FREE"); - - // 모달 상태 - const [isAIAnalysisModalOpen, setIsAIAnalysisModalOpen] = useState(false); - const [isMyPlanModalOpen, setIsMyPlanModalOpen] = useState(false); - const [isPaymentMethodModalOpen, setIsPaymentMethodModalOpen] = - useState(false); - const [isProfileEditModalOpen, setIsProfileEditModalOpen] = useState(false); - const [isRoutineRecommendModalOpen, setIsRoutineRecommendModalOpen] = - useState(false); - const [isMealRecommendModalOpen, setIsMealRecommendModalOpen] = - useState(false); - const [isDeleteAccountModalOpen, setIsDeleteAccountModalOpen] = - useState(false); - const [isPremiumModalOpen, setIsPremiumModalOpen] = useState(false); - - // 1. 외부에서 넘어온 파라미터 감지 (내 플랜 모달 열기) - useEffect(() => { - if (route.params?.openPremiumModal) { - console.log("🔓 마이페이지 진입: 프리미엄 모달 자동 오픈"); - setIsPremiumModalOpen(true); - navigation.setParams({ openPremiumModal: undefined }); - } - }, [route.params]); - - // 2. 초기 데이터 로드 - useFocusEffect( - React.useCallback(() => { - console.log("🔄 마이페이지 포커스 - 데이터 새로고침"); - fetchProfile(); - loadMembershipType(); - }, []) - ); - - const fetchProfile = async () => { - try { - setLoading(true); - const data = await authAPI.getProfile(); - setProfileData(data); - } catch (error: any) { - console.error("프로필 로드 실패:", error); - if (error.status === 401) navigation.replace("Login"); - } finally { - setLoading(false); - } - }; - - // 멤버십 타입 로드 - const loadMembershipType = async () => { - try { - const membershipType = await AsyncStorage.getItem("membershipType"); - if (membershipType) { - setCurrentMembershipType(membershipType as "FREE" | "PREMIUM"); - } - } catch (error) { - console.error("멤버십 타입 로드 실패:", error); - } - }; - - // 테스트용 멤버십 전환 함수 - const handleToggleMembership = async () => { - const newType = currentMembershipType === "FREE" ? "PREMIUM" : "FREE"; - - Alert.alert( - "멤버십 전환", - `${ - newType === "PREMIUM" ? "프리미엄" : "무료" - } 플랜으로 전환하시겠습니까?`, - [ - { text: "취소", style: "cancel" }, - { - text: "전환", - onPress: async () => { - try { - console.log( - "🔄 멤버십 전환 시작:", - currentMembershipType, - "→", - newType - ); - - // 1. 멤버십 전환 - const result = await authAPI.toggleMembership(); - - // ✅ 2. 프리미엄으로 전환한 경우 토큰 초기화 (무제한 활성화) - if (result.newType === "PREMIUM") { - try { - await authAPI.resetTokens(); - console.log("✅ 프리미엄 전환 + 토큰 초기화 완료"); - } catch (tokenError) { - console.warn("⚠️ 토큰 초기화 실패 (무시):", tokenError); - } - } - - setCurrentMembershipType(result.newType); - - // AsyncStorage에 멤버십 타입 업데이트 (다른 화면에서도 반영되도록) - await AsyncStorage.setItem("membershipType", result.newType); - console.log( - "✅ AsyncStorage에 멤버십 타입 업데이트:", - result.newType - ); - - Alert.alert( - "전환 완료 ✅", - result.message + - (result.newType === "PREMIUM" ? "\n\n 전환 완료" : ""), - [ - { - text: "확인", - onPress: () => { - loadMembershipType(); - }, - }, - ] - ); - - console.log("✅ 멤버십 전환 완료:", result); - } catch (error: any) { - console.error("❌ 멤버십 전환 실패:", error); - Alert.alert( - "오류", - error.message || "멤버십 전환에 실패했습니다." - ); - } - }, - }, - ] - ); - }; - - // ✅ 로그아웃 실행 함수 (즉시 실행용) - const executeLogout = async () => { - try { - await authAPI.logout(); - navigation.replace("Login"); - } catch (e) { - console.error(e); - navigation.replace("Login"); // 에러나도 일단 화면 이동 - } - }; - - // 버튼 클릭 시 확인창 띄우는 로그아웃 핸들러 - const handleLogoutPress = () => { - Alert.alert("로그아웃", "정말로 로그아웃하시겠습니까?", [ - { text: "취소", style: "cancel" }, - { text: "확인", onPress: executeLogout }, - ]); - }; - - // 단순 호출용 핸들러 (사용 안 함, handleLogoutPress 사용 권장) - const handleLogout = () => { - executeLogout(); - }; - - const handleDeleteAccount = () => { - setIsDeleteAccountModalOpen(true); - }; - - const getMembershipTypeText = (type: string) => { - switch (type) { - case "FREE": - return "무료 회원"; - case "PREMIUM": - return "프리미엄 회원"; - case "VIP": - return "풀업의 신"; - default: - return "무료 회원"; - } - }; - - if (loading) { - return ( - - - - 프로필을 불러오는 중... - - - ); - } - - if (!profileData) { - return ( - - - 프로필 정보를 불러올 수 없습니다 - - 다시 시도 - - - - ); - } - - return ( - - - 마이페이지 - - - - {/* 프로필 카드 */} - - - - - - - - - {profileData?.name || "사용자"}님 - - - - - {getMembershipTypeText(currentMembershipType)} - - {/* 현재 멤버십 상태 표시 */} - - - - - - - - setIsProfileEditModalOpen(true)} - > - - - - - - - {/* 테스트 섹션 */} - - 🧪 개발 테스트 (개발 전용) - - {/* 멤버십 전환 버튼 */} - - - - {currentMembershipType === "FREE" ? "🆓 → 💎" : "💎 → 🆓"}{" "} - 무료/유료 전환 - - - - {currentMembershipType === "FREE" ? "FREE" : "PREMIUM"} - - - - - - - - - navigation.navigate("PaymentSuccess")} - > - - ✅ 결제 성공 화면 - - - - - - - navigation.navigate("PaymentFail")} - > - - ❌ 결제 실패 화면 - - - - - - - navigation.navigate("PaymentCancel")} - > - - 🚫 결제 취소 화면 - - - - - - - - - {/* 구독/결제 */} - - 💳 구독/결제 - - setIsPremiumModalOpen(true)} - > - 구독 하기 - - - - - - setIsPaymentMethodModalOpen(true)} - > - 내 플랜 보기 - - - - - - - - {/* 추천 내역 */} - - 🌟 추천 내역 - - - - navigation.navigate("RoutineRecommend")} - > - 운동 추천 내역 - - - - - - navigation.navigate("MealRecommendHistory")} - > - 식단 추천 내역 - - - - - - - - {/* 계정 관리 */} - - ⚙️ 계정 관리 - - {/* ✅ 버튼에 확인창 있는 로그아웃 연결 */} - - 로그아웃 - - - - - - - 회원탈퇴 - - - - - - - {/* 모달들 */} - - {/* ✅ 1. 구독 정보 모달 (로그아웃 기능 연결됨) */} - setIsPaymentMethodModalOpen(false)} - onCancelSuccess={executeLogout} - /> - - setIsAIAnalysisModalOpen(false)} - /> - - setIsMyPlanModalOpen(false)} - navigation={navigation} - /> - - {/* ⚠️ 중복되었던 PaymentMethodModal 제거됨 */} - - setIsProfileEditModalOpen(false)} - profileData={profileData} - onProfileUpdate={fetchProfile} - /> - setIsRoutineRecommendModalOpen(false)} - /> - setIsMealRecommendModalOpen(false)} - /> - setIsDeleteAccountModalOpen(false)} - onDeleteSuccess={() => navigation.replace("Login")} - /> - setIsPremiumModalOpen(false)} - /> - - ); -}; - -const NEW_COLORS = { - background: "#1a1a1a", - text: "#f0f0f0", - text_secondary: "#a0a0a0", - accent: "#e3ff7c", - card_bg: "#252525", - separator: "#3a3a3a", - delete_color: "#ff6b6b", -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: NEW_COLORS.background, - }, - centerContent: { - justifyContent: "center", - alignItems: "center", - }, - loadingText: { - marginTop: 16, - fontSize: 16, - color: NEW_COLORS.text, - }, - errorText: { - fontSize: 16, - color: NEW_COLORS.delete_color, - marginBottom: 16, - }, - retryBtn: { - paddingHorizontal: 24, - paddingVertical: 12, - backgroundColor: NEW_COLORS.separator, - borderRadius: 8, - }, - retryBtnText: { - color: NEW_COLORS.text, - fontSize: 16, - fontWeight: "600", - }, - header: { - paddingVertical: 16, - paddingHorizontal: 20, - alignItems: "center", - justifyContent: "center", - }, - headerTitle: { - fontSize: 20, - fontWeight: "700", - color: NEW_COLORS.text, - }, - content: { - flex: 1, - paddingHorizontal: 20, - }, - profileCard: { - marginTop: 16, - marginBottom: 30, - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 20, - backgroundColor: NEW_COLORS.card_bg, - borderRadius: 15, - }, - profileInfo: { - flexDirection: "row", - alignItems: "center", - gap: 16, - flex: 1, - }, - profileAvatar: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: NEW_COLORS.separator, - justifyContent: "center", - alignItems: "center", - }, - profileDetails: { - flex: 1, - }, - username: { - flexDirection: "row", - alignItems: "center", - gap: 6, - marginBottom: 4, - }, - usernameText: { - fontSize: 20, - fontWeight: "bold", - color: NEW_COLORS.text, - }, - membershipBadgeContainer: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - userTitle: { - fontSize: 14, - color: NEW_COLORS.accent, - fontWeight: "500", - }, - membershipStatusBadge: { - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 8, - backgroundColor: NEW_COLORS.separator, - }, - premiumStatusBadge: { - backgroundColor: "#FFD70020", - }, - editProfileButton: { - padding: 8, - backgroundColor: NEW_COLORS.separator, - borderRadius: 10, - }, - section: { - paddingVertical: 16, - marginBottom: 8, - }, - separator: { - height: 1, - backgroundColor: NEW_COLORS.separator, - marginVertical: 10, - }, - subSeparator: { - height: 1, - backgroundColor: NEW_COLORS.separator, - marginVertical: 4, - marginLeft: 10, - }, - sectionTitle: { - fontSize: 18, - fontWeight: "600", - color: NEW_COLORS.text, - }, - sectionLinks: { - backgroundColor: NEW_COLORS.card_bg, - borderRadius: 15, - paddingHorizontal: 15, - paddingVertical: 5, - marginTop: 8, - }, - linkItem: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingVertical: 14, - }, - linkText: { - fontSize: 16, - color: NEW_COLORS.text, - fontWeight: "400", - }, - logoutText: { - color: NEW_COLORS.accent, - fontWeight: "500", - }, - deleteText: { - color: NEW_COLORS.delete_color, - fontWeight: "500", - }, - linkItemWithBadge: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, - newBadge: { - backgroundColor: NEW_COLORS.accent, - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 8, - }, - newBadgeText: { - fontSize: 10, - fontWeight: "700", - color: "#000", - }, - statusBadge: { - backgroundColor: NEW_COLORS.separator, - paddingHorizontal: 10, - paddingVertical: 3, - borderRadius: 10, - }, - premiumBadge: { - backgroundColor: "#FFD70020", - }, - statusBadgeText: { - fontSize: 11, - fontWeight: "700", - color: NEW_COLORS.accent, - }, -}); - -export default MyPageScreen; +// src/screens/main/MyPageScreen.tsx + +import React, { useState, useEffect } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + ScrollView, + Alert, + ActivityIndicator, +} 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 { authAPI } from "../../services"; +import AIAnalysisModal from "../../components/modals/AIAnalysisModal"; +import MyPlanModal from "../../components/modals/MyPlanModal"; +import PaymentMethodModal from "../../components/modals/PaymentMethodModal"; +import ProfileEditModal from "../../components/modals/ProfileEditModal"; +import DeleteAccountModal from "../../components/modals/DeleteAccountModal"; +import PremiumModal from "../../components/modals/PremiumModal"; +import { useRoute } from "@react-navigation/native"; +import { useFocusEffect } from "@react-navigation/native"; +import { getProfile } from "@react-native-seoul/kakao-login"; + +const MyPageScreen = ({ navigation }: any) => { + const route = useRoute(); + + const [profileData, setProfileData] = useState(null); + const [loading, setLoading] = useState(true); + const [currentMembershipType, setCurrentMembershipType] = useState< + "FREE" | "PREMIUM" + >("FREE"); + + // 모달 상태 + const [isAIAnalysisModalOpen, setIsAIAnalysisModalOpen] = useState(false); + const [isMyPlanModalOpen, setIsMyPlanModalOpen] = useState(false); + const [isPaymentMethodModalOpen, setIsPaymentMethodModalOpen] = + useState(false); + const [isProfileEditModalOpen, setIsProfileEditModalOpen] = useState(false); + const [isRoutineRecommendModalOpen, setIsRoutineRecommendModalOpen] = + useState(false); + const [isMealRecommendModalOpen, setIsMealRecommendModalOpen] = + useState(false); + const [isDeleteAccountModalOpen, setIsDeleteAccountModalOpen] = + useState(false); + const [isPremiumModalOpen, setIsPremiumModalOpen] = useState(false); + + // 1. 외부에서 넘어온 파라미터 감지 (내 플랜 모달 열기) + useEffect(() => { + if (route.params?.openPremiumModal) { + console.log("🔓 마이페이지 진입: 프리미엄 모달 자동 오픈"); + setIsPremiumModalOpen(true); + navigation.setParams({ openPremiumModal: undefined }); + } + }, [route.params]); + + // 2. 초기 데이터 로드 + useFocusEffect( + React.useCallback(() => { + console.log("🔄 마이페이지 포커스 - 데이터 새로고침"); + fetchProfile(); + loadMembershipType(); + }, []) + ); + + const fetchProfile = async () => { + try { + setLoading(true); + const data = await authAPI.getProfile(); + setProfileData(data); + } catch (error: any) { + console.error("프로필 로드 실패:", error); + if (error.status === 401) navigation.replace("Login"); + } finally { + setLoading(false); + } + }; + + // 멤버십 타입 로드 + const loadMembershipType = async () => { + try { + const membershipType = await AsyncStorage.getItem("membershipType"); + if (membershipType) { + setCurrentMembershipType(membershipType as "FREE" | "PREMIUM"); + } + } catch (error) { + console.error("멤버십 타입 로드 실패:", error); + } + }; + + // 테스트용 멤버십 전환 함수 + const handleToggleMembership = async () => { + const newType = currentMembershipType === "FREE" ? "PREMIUM" : "FREE"; + + Alert.alert( + "멤버십 전환", + `${ + newType === "PREMIUM" ? "프리미엄" : "무료" + } 플랜으로 전환하시겠습니까?`, + [ + { text: "취소", style: "cancel" }, + { + text: "전환", + onPress: async () => { + try { + console.log( + "🔄 멤버십 전환 시작:", + currentMembershipType, + "→", + newType + ); + + // 1. 멤버십 전환 + const result = await authAPI.toggleMembership(); + + // ✅ 2. 프리미엄으로 전환한 경우 토큰 초기화 (무제한 활성화) + if (result.newType === "PREMIUM") { + try { + await authAPI.resetTokens(); + console.log("✅ 프리미엄 전환 + 토큰 초기화 완료"); + } catch (tokenError) { + console.warn("⚠️ 토큰 초기화 실패 (무시):", tokenError); + } + } + + setCurrentMembershipType(result.newType); + + // AsyncStorage에 멤버십 타입 업데이트 (다른 화면에서도 반영되도록) + await AsyncStorage.setItem("membershipType", result.newType); + console.log( + "✅ AsyncStorage에 멤버십 타입 업데이트:", + result.newType + ); + + Alert.alert( + "전환 완료 ✅", + result.message + + (result.newType === "PREMIUM" ? "\n\n 전환 완료" : ""), + [ + { + text: "확인", + onPress: () => { + loadMembershipType(); + }, + }, + ] + ); + + console.log("✅ 멤버십 전환 완료:", result); + } catch (error: any) { + console.error("❌ 멤버십 전환 실패:", error); + Alert.alert( + "오류", + error.message || "멤버십 전환에 실패했습니다." + ); + } + }, + }, + ] + ); + }; + + // ✅ 로그아웃 실행 함수 (즉시 실행용) + const executeLogout = async () => { + try { + await authAPI.logout(); + navigation.replace("Login"); + } catch (e) { + console.error(e); + navigation.replace("Login"); // 에러나도 일단 화면 이동 + } + }; + + // 버튼 클릭 시 확인창 띄우는 로그아웃 핸들러 + const handleLogoutPress = () => { + Alert.alert("로그아웃", "정말로 로그아웃하시겠습니까?", [ + { text: "취소", style: "cancel" }, + { text: "확인", onPress: executeLogout }, + ]); + }; + + // 단순 호출용 핸들러 (사용 안 함, handleLogoutPress 사용 권장) + const handleLogout = () => { + executeLogout(); + }; + + const handleDeleteAccount = () => { + setIsDeleteAccountModalOpen(true); + }; + + const getMembershipTypeText = (type: string) => { + switch (type) { + case "FREE": + return "무료 회원"; + case "PREMIUM": + return "프리미엄 회원"; + case "VIP": + return "풀업의 신"; + default: + return "무료 회원"; + } + }; + + if (loading) { + return ( + + + + 프로필을 불러오는 중... + + + ); + } + + if (!profileData) { + return ( + + + 프로필 정보를 불러올 수 없습니다 + + 다시 시도 + + + + ); + } + + return ( + + + 마이페이지 + + + + {/* 프로필 카드 */} + + + + + + + + + {profileData?.name || "사용자"}님 + + + + + {getMembershipTypeText(currentMembershipType)} + + {/* 현재 멤버십 상태 표시 */} + + + + + + + + setIsProfileEditModalOpen(true)} + > + + + + + + + {/* 테스트 섹션 */} + + 🧪 개발 테스트 (개발 전용) + + {/* 멤버십 전환 버튼 */} + + + + {currentMembershipType === "FREE" ? "🆓 → 💎" : "💎 → 🆓"}{" "} + 무료/유료 전환 + + + + {currentMembershipType === "FREE" ? "FREE" : "PREMIUM"} + + + + + + + + + navigation.navigate("PaymentSuccess")} + > + + ✅ 결제 성공 화면 + + + + + + + navigation.navigate("PaymentFail")} + > + + ❌ 결제 실패 화면 + + + + + + + navigation.navigate("PaymentCancel")} + > + + 🚫 결제 취소 화면 + + + + + + + + + {/* 구독/결제 */} + + 💳 구독/결제 + + setIsPremiumModalOpen(true)} + > + 구독 하기 + + + + + + setIsPaymentMethodModalOpen(true)} + > + 내 플랜 보기 + + + + + + + + {/* 추천 내역 */} + + 🌟 추천 내역 + + + + navigation.navigate("RoutineRecommend")} + > + 운동 추천 내역 + + + + + + navigation.navigate("MealRecommendHistory")} + > + 식단 추천 내역 + + + + + + + + {/* 계정 관리 */} + + ⚙️ 계정 관리 + + {/* ✅ 버튼에 확인창 있는 로그아웃 연결 */} + + 로그아웃 + + + + + + + 회원탈퇴 + + + + + + + {/* 모달들 */} + + {/* ✅ 1. 구독 정보 모달 (로그아웃 기능 연결됨) */} + setIsPaymentMethodModalOpen(false)} + onCancelSuccess={executeLogout} + /> + + setIsAIAnalysisModalOpen(false)} + /> + + setIsMyPlanModalOpen(false)} + navigation={navigation} + /> + + setIsProfileEditModalOpen(false)} + profileData={profileData} + onProfileUpdate={fetchProfile} + /> + + setIsDeleteAccountModalOpen(false)} + onDeleteSuccess={() => navigation.replace("Login")} + /> + setIsPremiumModalOpen(false)} + /> + + ); +}; + +const NEW_COLORS = { + background: "#1a1a1a", + text: "#f0f0f0", + text_secondary: "#a0a0a0", + accent: "#e3ff7c", + card_bg: "#252525", + separator: "#3a3a3a", + delete_color: "#ff6b6b", +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: NEW_COLORS.background, + }, + centerContent: { + justifyContent: "center", + alignItems: "center", + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: NEW_COLORS.text, + }, + errorText: { + fontSize: 16, + color: NEW_COLORS.delete_color, + marginBottom: 16, + }, + retryBtn: { + paddingHorizontal: 24, + paddingVertical: 12, + backgroundColor: NEW_COLORS.separator, + borderRadius: 8, + }, + retryBtnText: { + color: NEW_COLORS.text, + fontSize: 16, + fontWeight: "600", + }, + header: { + paddingVertical: 16, + paddingHorizontal: 20, + alignItems: "center", + justifyContent: "center", + }, + headerTitle: { + fontSize: 20, + fontWeight: "700", + color: NEW_COLORS.text, + }, + content: { + flex: 1, + paddingHorizontal: 20, + }, + profileCard: { + marginTop: 16, + marginBottom: 30, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + padding: 20, + backgroundColor: NEW_COLORS.card_bg, + borderRadius: 15, + }, + profileInfo: { + flexDirection: "row", + alignItems: "center", + gap: 16, + flex: 1, + }, + profileAvatar: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: NEW_COLORS.separator, + justifyContent: "center", + alignItems: "center", + }, + profileDetails: { + flex: 1, + }, + username: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginBottom: 4, + }, + usernameText: { + fontSize: 20, + fontWeight: "bold", + color: NEW_COLORS.text, + }, + membershipBadgeContainer: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + userTitle: { + fontSize: 14, + color: NEW_COLORS.accent, + fontWeight: "500", + }, + membershipStatusBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + backgroundColor: NEW_COLORS.separator, + }, + premiumStatusBadge: { + backgroundColor: "#FFD70020", + }, + editProfileButton: { + padding: 8, + backgroundColor: NEW_COLORS.separator, + borderRadius: 10, + }, + section: { + paddingVertical: 16, + marginBottom: 8, + }, + separator: { + height: 1, + backgroundColor: NEW_COLORS.separator, + marginVertical: 10, + }, + subSeparator: { + height: 1, + backgroundColor: NEW_COLORS.separator, + marginVertical: 4, + marginLeft: 10, + }, + sectionTitle: { + fontSize: 18, + fontWeight: "600", + color: NEW_COLORS.text, + }, + sectionLinks: { + backgroundColor: NEW_COLORS.card_bg, + borderRadius: 15, + paddingHorizontal: 15, + paddingVertical: 5, + marginTop: 8, + }, + linkItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 14, + }, + linkText: { + fontSize: 16, + color: NEW_COLORS.text, + fontWeight: "400", + }, + logoutText: { + color: NEW_COLORS.accent, + fontWeight: "500", + }, + deleteText: { + color: NEW_COLORS.delete_color, + fontWeight: "500", + }, + linkItemWithBadge: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + newBadge: { + backgroundColor: NEW_COLORS.accent, + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 8, + }, + newBadgeText: { + fontSize: 10, + fontWeight: "700", + color: "#000", + }, + statusBadge: { + backgroundColor: NEW_COLORS.separator, + paddingHorizontal: 10, + paddingVertical: 3, + borderRadius: 10, + }, + premiumBadge: { + backgroundColor: "#FFD70020", + }, + statusBadgeText: { + fontSize: 11, + fontWeight: "700", + color: NEW_COLORS.accent, + }, +}); + +export default MyPageScreen; diff --git a/src/services/authAPI.ts b/src/services/authAPI.ts index 08a8994..676d58f 100644 --- a/src/services/authAPI.ts +++ b/src/services/authAPI.ts @@ -1,872 +1,872 @@ -// src/services/authAPI.ts -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { - request, - ApiResponse, - ACCESS_TOKEN_KEY, - REFRESH_TOKEN_KEY, -} from "./apiConfig"; - -/** - * JWT 토큰에서 페이로드 추출 - */ -const decodeJWT = (token: string): any => { - try { - const base64Url = token.split(".")[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const jsonPayload = decodeURIComponent( - atob(base64) - .split("") - .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) - .join("") - ); - return JSON.parse(jsonPayload); - } catch (error) { - console.error("[AUTH] JWT 디코딩 실패:", error); - return null; - } -}; - -/** - * 인증 관련 API 함수들 - * 회원가입, 로그인, 비밀번호 찾기 등 - */ -export const authAPI = { - /** - * 아이디 중복 확인 - * @param userId 확인할 아이디 - * @returns 사용 가능 여부와 메시지 - */ - checkUserId: async ( - userId: string - ): Promise<{ available: boolean; message: string }> => { - return request<{ available: boolean; message: string }>( - `/api/users/check-userId?userId=${userId}`, - { - method: "GET", - } - ); - }, - - /** - * 이메일 인증코드 발송 - * @param email 인증코드를 받을 이메일 주소 - * @returns 성공 여부와 메시지 - */ - sendVerificationCode: async (email: string): Promise => { - try { - console.log("이메일 인증코드 발송 요청:", email); - const response = await request( - "/api/users/send-verification-code", - { - method: "POST", - body: JSON.stringify({ email }), - } - ); - console.log("이메일 인증코드 발송 응답:", response); - return response; - } catch (error: any) { - console.error("이메일 인증코드 발송 에러:", error); - - if ( - error.status === 500 || - error.message?.includes("500") || - error.message?.includes("서버 내부 오류") - ) { - throw new Error( - "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." - ); - } - - if (error.status === 400 || error.message?.includes("400")) { - throw new Error("잘못된 이메일 형식입니다"); - } - - throw error; - } - }, - - /** - * 회원가입 - * @param signupData 회원가입 정보 (아이디, 비밀번호, 이메일 등) - * @returns 생성된 사용자 ID - */ - signup: async (signupData: { - userId: string; - name: string; - email: string; - password: string; - passwordConfirm: string; - birthDate: string; - verificationCode: string; - gender: "M" | "F"; - height: number; - weight: number; - weightGoal: number; - healthGoal: string; - workoutDaysPerWeek?: string; - agreeTerms: boolean; - agreePrivacy: boolean; - }): Promise> => { - return request>("/api/users/signup", { - method: "POST", - body: JSON.stringify(signupData), - }); - }, - - /** - * 로그인 - * @param userId 사용자 아이디 - * @param password 비밀번호 - * @returns 토큰 정보 (자동으로 AsyncStorage에 저장됨) - */ - login: async ( - userId: string, - password: string - ): Promise<{ - success: boolean; - message: string; - accessToken?: string; - refreshToken?: string; - tokenType?: string; - expiresIn?: number; - membershipType?: "FREE" | "PREMIUM"; - }> => { - try { - const response = await request<{ - success: boolean; - message: string; - accessToken?: string; - refreshToken?: string; - tokenType?: string; - expiresIn?: number; - membershipType?: "FREE" | "PREMIUM"; - }>("/api/users/login", { - method: "POST", - body: JSON.stringify({ userId, password }), - }); - - if (response.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); - - const payload = decodeJWT(response.accessToken); - if (payload && payload.userPk) { - await AsyncStorage.setItem("userId", String(payload.userPk)); - console.log("[AUTH] userId 저장 완료:", payload.userPk); - } else { - console.warn("[AUTH] JWT에서 userPk를 찾을 수 없습니다"); - } - } - - if (response.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.refreshToken); - } - - if (response.membershipType) { - await AsyncStorage.setItem("membershipType", response.membershipType); - console.log( - "[AUTH] membershipType 저장 완료:", - response.membershipType - ); - } else { - await AsyncStorage.setItem("membershipType", "FREE"); - console.log("[AUTH] membershipType 기본값(FREE) 저장"); - } - - return response; - } catch (error: any) { - if ( - error.status === 401 || - error.message?.includes("401") || - error.message?.includes("올바르지 않습니다") - ) { - throw new Error("아이디 또는 비밀번호가 올바르지 않습니다"); - } - throw error; - } - }, - - /** - * 아이디 찾기 - * @param email 등록된 이메일 주소 - * @returns 마스킹된 아이디와 메시지 - */ - findUserId: async ( - email: string - ): Promise<{ - success: boolean; - message: string; - maskedUserId?: string; - }> => { - return request<{ - success: boolean; - message: string; - maskedUserId?: string; - }>("/api/users/find-userId", { - method: "POST", - body: JSON.stringify({ email }), - }); - }, - - /** - * 비밀번호 재설정 요청 - * 이메일로 임시 비밀번호를 발송합니다 - * @param email 등록된 이메일 주소 - * @returns 성공 여부와 메시지 - */ - resetPassword: async (email: string): Promise => { - return request("/api/users/reset-password", { - method: "POST", - body: JSON.stringify({ email }), - }); - }, - - /** - * 비밀번호 변경 - * 임시 비밀번호를 사용해 새 비밀번호로 변경 - * @param tempPassword 임시 비밀번호 - * @param newPassword 새 비밀번호 - * @param newPasswordConfirm 새 비밀번호 확인 - * @returns 성공 여부와 메시지 - */ - changePassword: async ( - tempPassword: string, - newPassword: string, - newPasswordConfirm: string - ): Promise => { - try { - return await request("/api/users/change-password", { - method: "PUT", - body: JSON.stringify({ - tempPassword, - newPassword, - newPasswordConfirm, - }), - }); - } catch (error: any) { - if ( - error.status === 400 || - error.message?.includes("400") || - error.message?.includes("올바르지 않거나 만료") - ) { - throw new Error("임시 비밀번호가 올바르지 않거나 만료되었습니다"); - } - - if ( - error.status === 404 || - error.message?.includes("404") || - error.message?.includes("찾을 수 없습니다") - ) { - throw new Error("임시 비밀번호에 해당하는 사용자를 찾을 수 없습니다"); - } - - throw error; - } - }, - - /** - * 로그아웃 - * 서버에 로그아웃 요청 후 로컬 토큰 삭제 - */ - logout: async (): Promise => { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - - if (token) { - try { - await request("/api/users/logout", { - method: "POST", - body: JSON.stringify({ accessToken: token }), - }); - } catch (error) { - console.error("Logout error:", error); - } - } - - await AsyncStorage.removeItem(ACCESS_TOKEN_KEY); - await AsyncStorage.removeItem(REFRESH_TOKEN_KEY); - await AsyncStorage.removeItem("userId"); - await AsyncStorage.removeItem("userName"); - await AsyncStorage.removeItem("membershipType"); - await AsyncStorage.removeItem("chatbot_tokens"); - }, - - /** - * 토큰 재발급 - * 만료된 accessToken을 refreshToken으로 새로 발급받음 - * @returns 새로 발급된 토큰 정보 - */ - refreshToken: async (): Promise<{ - success: boolean; - message: string; - accessToken?: string; - tokenType?: string; - expiresIn?: number; - }> => { - const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY); - if (!refreshToken) { - throw new Error("No refresh token available"); - } - - const response = await request<{ - success: boolean; - message: string; - accessToken?: string; - tokenType?: string; - expiresIn?: number; - }>("/api/users/refresh-token", { - method: "POST", - body: JSON.stringify({ refreshToken }), - }); - - if (response.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); - - const payload = decodeJWT(response.accessToken); - if (payload && payload.userPk) { - await AsyncStorage.setItem("userId", String(payload.userPk)); - console.log("[AUTH] userId 재저장 완료 (토큰 갱신):", payload.userPk); - } - } - - return response; - }, - - /** - * 현재 저장된 토큰 가져오기 - * @returns accessToken 또는 null - */ - getAccessToken: async (): Promise => { - return await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - }, - - /** - * 로그인 상태 확인 - * @returns 토큰 존재 여부 (true = 로그인됨, false = 비로그인) - */ - isAuthenticated: async (): Promise => { - const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - return !!token; - }, - - /** - * 사용자 프로필 조회 - */ - getProfile: async (): Promise<{ - id: number; - userId: string; - name: string; - email: string; - birthDate: string; - height: number; - weight: number; - weightGoal: number; - gender: "M" | "F"; - membershipType: string; - healthGoal: string; - workoutDaysPerWeek?: string; - lastLoginAt: string; - createdAt: string; - mealRecommendTokens?: number; - workoutRecommendTokens?: number; - chatbotTokens?: number; - mealRecommendLastReset?: string; - workoutRecommendLastReset?: string; - chatbotLastReset?: string; - }> => { - return request<{ - id: number; - userId: string; - name: string; - email: string; - birthDate: string; - height: number; - weight: number; - weightGoal: number; - gender: "M" | "F"; - membershipType: string; - healthGoal: string; - workoutDaysPerWeek?: string; - lastLoginAt: string; - createdAt: string; - mealRecommendTokens?: number; - workoutRecommendTokens?: number; - chatbotTokens?: number; - mealRecommendLastReset?: string; - workoutRecommendLastReset?: string; - chatbotLastReset?: string; - }>("/api/profile", { - method: "GET", - }); - }, - - /** - * 사용자 프로필 수정 - */ - updateProfile: async (profileData: { - name?: string; - height?: number; - weight?: number; - gender?: "M" | "F"; - healthGoal?: string; - workoutDaysPerWeek?: string; - weightGoal?: number; - }): Promise<{ - success: boolean; - message: string; - profile?: { - id: number; - userId: string; - name: string; - email: string; - birthDate: string; - height: number; - weight: number; - gender: "M" | "F"; - membershipType: string; - healthGoal: string; - workoutDaysPerWeek?: string; - weightGoal: number; - lastLoginAt: string; - createdAt: string; - }; - }> => { - return request<{ - success: boolean; - message: string; - profile?: any; - }>("/api/profile", { - method: "PUT", - body: JSON.stringify(profileData), - }); - }, - - /** - * 로그인 상태에서 비밀번호 변경 - */ - changePasswordLoggedIn: async ( - currentPassword: string, - newPassword: string, - newPasswordConfirm: string - ): Promise<{ - success: boolean; - message: string; - }> => { - return request<{ - success: boolean; - message: string; - }>("/api/profile/password", { - method: "PUT", - body: JSON.stringify({ - currentPassword, - newPassword, - newPasswordConfirm, - }), - }); - }, - - /** - * 회원탈퇴 - */ - deleteAccount: async ( - password: string, - reason: string - ): Promise<{ - success: boolean; - message: string; - }> => { - try { - const response = await request<{ - success: boolean; - message: string; - }>("/api/profile", { - method: "DELETE", - body: JSON.stringify({ - password, - reason, - }), - }); - - if (response.success) { - await AsyncStorage.removeItem(ACCESS_TOKEN_KEY); - await AsyncStorage.removeItem(REFRESH_TOKEN_KEY); - await AsyncStorage.removeItem("userId"); - await AsyncStorage.removeItem("userName"); - await AsyncStorage.removeItem("membershipType"); - await AsyncStorage.removeItem("chatbot_tokens"); - } - - return response; - } catch (error: any) { - console.error("회원탈퇴 에러:", error); - throw error; - } - }, - - // ======================================== - // ✅ 멤버십 관련 API (신규 추가) - // ======================================== - - /** - * 토큰 초기화 (테스트용) - * 모든 토큰을 기본값으로 초기화 - * - 챗봇 토큰: 3개 - * - 식단 추천 토큰: 1개 - * - 운동 추천 토큰: 1개 - */ - resetTokens: async (): Promise => { - try { - const response = await request<{ message: string }>( - "/api/users/tokens/reset", - { - method: "POST", - } - ); - - // 로컬 스토리지도 초기화 - await AsyncStorage.setItem("chatbot_tokens", "3"); - - console.log("[AUTH] 토큰 초기화 완료:", response); - return response.message || "토큰이 기본값으로 초기화되었습니다."; - } catch (error: any) { - console.error("토큰 초기화 에러:", error); - throw error; - } - }, - - /** - * 프리미엄으로 업그레이드 - * 무료 → 프리미엄으로 변경 - */ - upgradeToPremium: async (): Promise => { - try { - const response = await request<{ message: string }>( - "/api/users/membership/premium", - { - method: "POST", - } - ); - - // AsyncStorage도 업데이트 - await AsyncStorage.setItem("membershipType", "PREMIUM"); - console.log("[AUTH] 프리미엄 업그레이드 완료:", response); - - return response.message || "멤버십이 프리미엄으로 변경되었습니다."; - } catch (error: any) { - console.error("프리미엄 업그레이드 에러:", error); - throw error; - } - }, - - /** - * 무료로 다운그레이드 - * 프리미엄 → 무료로 변경 - */ - downgradeToFree: async (): Promise => { - try { - const response = await request<{ message: string }>( - "/api/users/membership/free", - { - method: "POST", - } - ); - - // AsyncStorage도 업데이트 - await AsyncStorage.setItem("membershipType", "FREE"); - await AsyncStorage.setItem("chatbot_tokens", "3"); - console.log("[AUTH] 무료 플랜 전환 완료:", response); - - return response.message || "멤버십이 무료 플랜으로 변경되었습니다."; - } catch (error: any) { - console.error("무료 플랜 전환 에러:", error); - throw error; - } - }, - - /** - * 멤버십 토글 (테스트용) - * 현재 상태를 확인하고 반대로 변경 - * FREE ↔ PREMIUM - */ - toggleMembership: async (): Promise<{ - newType: "FREE" | "PREMIUM"; - message: string; - }> => { - try { - // 현재 멤버십 타입 확인 - const currentType = await AsyncStorage.getItem("membershipType"); - - let message: string; - let newType: "FREE" | "PREMIUM"; - - if (currentType === "PREMIUM") { - // 프리미엄 → 무료 - message = await authAPI.downgradeToFree(); - newType = "FREE"; - } else { - // 무료 → 프리미엄 - message = await authAPI.upgradeToPremium(); - newType = "PREMIUM"; - } - - console.log("[AUTH] 멤버십 토글 완료:", currentType, "→", newType); - return { newType, message }; - } catch (error: any) { - console.error("멤버십 토글 에러:", error); - throw error; - } - }, - - /** - * AI 식단 추천 생성 - * @returns 추천된 식단 계획 - */ - generateMealPlan: async (): Promise<{ - id: number; - planName: string; - description: string; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - recommendationReason: string; - isSaved: boolean; - meals: Array<{ - id: number; - mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; - mealTypeName: string; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - foods: Array<{ - id: number; - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; - }>; - }>; - createdAt: string; - }> => { - return request("/api/recommended-meals/generate", { - method: "POST", - }); - }, - - /** - * 추천 식단 저장 - * @param planId 저장할 식단 계획 ID - * @returns 저장 결과 및 저장된 식단 정보 - */ - saveMealPlan: async ( - planId: number - ): Promise<{ - success: boolean; - message: string; - plan: { - id: number; - planName: string; - isSaved: boolean; - }; - }> => { - return request<{ - success: boolean; - message: string; - plan: any; - }>(`/api/recommended-meals/${planId}/save`, { - method: "POST", - }); - }, - - /** - * 저장된 식단 목록 조회 - * @returns 저장된 식단 목록 - */ - getSavedMealPlans: async (): Promise> => { - return request>("/api/recommended-meals/saved", { - method: "GET", - }); - }, - - /** - * 저장된 식단 삭제 - * @param planId 삭제할 식단 ID - * @returns 삭제 결과 - */ - deleteMealPlan: async ( - planId: number - ): Promise<{ - success: boolean; - message: string; - }> => { - return request<{ - success: boolean; - message: string; - }>(`/api/recommended-meals/${planId}`, { - method: "DELETE", - }); - }, - - /** - * 카카오 로그인 (OAuth 인증 코드로 로그인) - * @param code 카카오 OAuth 인증 코드 - * @returns 로그인 결과 및 토큰 - */ - kakaoLogin: async ( - code: string - ): Promise<{ - accessToken: string; - refreshToken: string; - userId: number; - nickname: string; - profileImageUrl: string; - newUser: boolean; - onboarded: boolean; - }> => { - try { - const response = await request<{ - accessToken: string; - refreshToken: string; - userId: number; - nickname: string; - profileImageUrl: string; - newUser: boolean; - onboarded: boolean; - }>("/api/auth/kakao/login", { - method: "POST", - body: JSON.stringify({ code }), - }); - - if (response.accessToken) { - await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); - - // userId 저장 - if (response.userId) { - await AsyncStorage.setItem("userId", String(response.userId)); - console.log("[AUTH] 카카오 로그인 - userId 저장 완료:", response.userId); - } - - // JWT에서도 userId 추출 시도 - const payload = decodeJWT(response.accessToken); - if (payload && payload.userPk && !response.userId) { - await AsyncStorage.setItem("userId", String(payload.userPk)); - } - } - - if (response.refreshToken) { - await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.refreshToken); - } - - // membershipType은 기본값으로 설정 (백엔드 응답에 없음) - await AsyncStorage.setItem("membershipType", "FREE"); - - return response; - } catch (error: any) { - console.error("[AUTH] 카카오 로그인 실패:", error); - throw error; - } - }, - - /** - * 카카오 로그인 URL 가져오기 - * @returns 카카오 로그인 URL - */ - getKakaoLoginUrl: async (): Promise<{ url: string }> => { - return request<{ url: string }>("/api/auth/kakao/login-url", { - method: "GET", - }); - }, - - /** - * 카카오 로그아웃 - */ - kakaoLogout: async (): Promise<{ message: string }> => { - try { - const response = await request<{ message: string }>("/api/auth/kakao/logout", { - method: "POST", - }); - return response; - } catch (error: any) { - console.error("[AUTH] 카카오 로그아웃 실패:", error); - throw error; - } - }, - - /** - * 카카오 연결 해제 - */ - kakaoUnlink: async (): Promise<{ message: string }> => { - try { - const response = await request<{ message: string }>("/api/auth/kakao/unlink", { - method: "DELETE", - }); - return response; - } catch (error: any) { - console.error("[AUTH] 카카오 연결 해제 실패:", error); - throw error; - } - }, - - /** - * 카카오 로그인 후 온보딩 데이터 제출 - * @param onboardingData 온보딩 정보 - * @returns 성공 여부 - */ - submitKakaoOnboarding: async (onboardingData: { - gender: "M" | "F"; - height: number; - weight: number; - weightGoal: number; - healthGoal: string; - workoutDaysPerWeek: string; - experienceLevel?: string; - fitnessConcerns?: string; - }): Promise<{ success: boolean; message: string }> => { - try { - const response = await request<{ success: boolean; message: string }>( - "/api/auth/kakao/onboarding", - { - method: "POST", - body: JSON.stringify(onboardingData), - } - ); - return response; - } catch (error: any) { - console.error("[AUTH] 카카오 온보딩 데이터 제출 실패:", error); - throw error; - } - }, - - /** - * 온보딩 데이터 제출 (신체정보 수기입력) - * @param onboardingData 온보딩 정보 - * @returns 성공 여부 - */ - submitOnboarding: async (onboardingData: { - gender: "M" | "F"; - height: number; - weight: number; - birthDate: string; - weightGoal: number; - healthGoal: string; - workoutDaysPerWeek: string; - experienceLevel?: string; - fitnessConcerns?: string; - }): Promise<{ success: boolean; message: string }> => { - try { - const response = await request<{ success: boolean; message: string }>( - "/api/users/onboarding", - { - method: "POST", - body: JSON.stringify(onboardingData), - } - ); - return response; - } catch (error: any) { - console.error("[AUTH] 온보딩 데이터 제출 실패:", error); - throw error; - } - }, -}; +// src/services/authAPI.ts +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + request, + ApiResponse, + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, +} from "./apiConfig"; + +/** + * JWT 토큰에서 페이로드 추출 + */ +const decodeJWT = (token: string): any => { + try { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + return JSON.parse(jsonPayload); + } catch (error) { + console.error("[AUTH] JWT 디코딩 실패:", error); + return null; + } +}; + +/** + * 인증 관련 API 함수들 + * 회원가입, 로그인, 비밀번호 찾기 등 + */ +export const authAPI = { + /** + * 아이디 중복 확인 + * @param userId 확인할 아이디 + * @returns 사용 가능 여부와 메시지 + */ + checkUserId: async ( + userId: string + ): Promise<{ available: boolean; message: string }> => { + return request<{ available: boolean; message: string }>( + `/api/users/check-userId?userId=${userId}`, + { + method: "GET", + } + ); + }, + + /** + * 이메일 인증코드 발송 + * @param email 인증코드를 받을 이메일 주소 + * @returns 성공 여부와 메시지 + */ + sendVerificationCode: async (email: string): Promise => { + try { + console.log("이메일 인증코드 발송 요청:", email); + const response = await request( + "/api/users/send-verification-code", + { + method: "POST", + body: JSON.stringify({ email }), + } + ); + console.log("이메일 인증코드 발송 응답:", response); + return response; + } catch (error: any) { + console.error("이메일 인증코드 발송 에러:", error); + + if ( + error.status === 500 || + error.message?.includes("500") || + error.message?.includes("서버 내부 오류") + ) { + throw new Error( + "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + ); + } + + if (error.status === 400 || error.message?.includes("400")) { + throw new Error("잘못된 이메일 형식입니다"); + } + + throw error; + } + }, + + /** + * 회원가입 + * @param signupData 회원가입 정보 (아이디, 비밀번호, 이메일 등) + * @returns 생성된 사용자 ID + */ + signup: async (signupData: { + userId: string; + name: string; + email: string; + password: string; + passwordConfirm: string; + birthDate: string; + verificationCode: string; + gender: "M" | "F"; + height: number; + weight: number; + weightGoal: number; + healthGoal: string; + workoutDaysPerWeek?: string; + agreeTerms: boolean; + agreePrivacy: boolean; + }): Promise> => { + return request>("/api/users/signup", { + method: "POST", + body: JSON.stringify(signupData), + }); + }, + + /** + * 로그인 + * @param userId 사용자 아이디 + * @param password 비밀번호 + * @returns 토큰 정보 (자동으로 AsyncStorage에 저장됨) + */ + login: async ( + userId: string, + password: string + ): Promise<{ + success: boolean; + message: string; + accessToken?: string; + refreshToken?: string; + tokenType?: string; + expiresIn?: number; + membershipType?: "FREE" | "PREMIUM"; + }> => { + try { + const response = await request<{ + success: boolean; + message: string; + accessToken?: string; + refreshToken?: string; + tokenType?: string; + expiresIn?: number; + membershipType?: "FREE" | "PREMIUM"; + }>("/api/users/login", { + method: "POST", + body: JSON.stringify({ userId, password }), + }); + + if (response.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); + + const payload = decodeJWT(response.accessToken); + if (payload && payload.userPk) { + await AsyncStorage.setItem("userId", String(payload.userPk)); + console.log("[AUTH] userId 저장 완료:", payload.userPk); + } else { + console.warn("[AUTH] JWT에서 userPk를 찾을 수 없습니다"); + } + } + + if (response.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.refreshToken); + } + + if (response.membershipType) { + await AsyncStorage.setItem("membershipType", response.membershipType); + console.log( + "[AUTH] membershipType 저장 완료:", + response.membershipType + ); + } else { + await AsyncStorage.setItem("membershipType", "FREE"); + console.log("[AUTH] membershipType 기본값(FREE) 저장"); + } + + return response; + } catch (error: any) { + if ( + error.status === 401 || + error.message?.includes("401") || + error.message?.includes("올바르지 않습니다") + ) { + throw new Error("아이디 또는 비밀번호가 올바르지 않습니다"); + } + throw error; + } + }, + + /** + * 아이디 찾기 + * @param email 등록된 이메일 주소 + * @returns 마스킹된 아이디와 메시지 + */ + findUserId: async ( + email: string + ): Promise<{ + success: boolean; + message: string; + maskedUserId?: string; + }> => { + return request<{ + success: boolean; + message: string; + maskedUserId?: string; + }>("/api/users/find-userId", { + method: "POST", + body: JSON.stringify({ email }), + }); + }, + + /** + * 비밀번호 재설정 요청 + * 이메일로 임시 비밀번호를 발송합니다 + * @param email 등록된 이메일 주소 + * @returns 성공 여부와 메시지 + */ + resetPassword: async (email: string): Promise => { + return request("/api/users/reset-password", { + method: "POST", + body: JSON.stringify({ email }), + }); + }, + + /** + * 비밀번호 변경 + * 임시 비밀번호를 사용해 새 비밀번호로 변경 + * @param tempPassword 임시 비밀번호 + * @param newPassword 새 비밀번호 + * @param newPasswordConfirm 새 비밀번호 확인 + * @returns 성공 여부와 메시지 + */ + changePassword: async ( + tempPassword: string, + newPassword: string, + newPasswordConfirm: string + ): Promise => { + try { + return await request("/api/users/change-password", { + method: "PUT", + body: JSON.stringify({ + tempPassword, + newPassword, + newPasswordConfirm, + }), + }); + } catch (error: any) { + if ( + error.status === 400 || + error.message?.includes("400") || + error.message?.includes("올바르지 않거나 만료") + ) { + throw new Error("임시 비밀번호가 올바르지 않거나 만료되었습니다"); + } + + if ( + error.status === 404 || + error.message?.includes("404") || + error.message?.includes("찾을 수 없습니다") + ) { + throw new Error("임시 비밀번호에 해당하는 사용자를 찾을 수 없습니다"); + } + + throw error; + } + }, + + /** + * 로그아웃 + * 서버에 로그아웃 요청 후 로컬 토큰 삭제 + */ + logout: async (): Promise => { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + if (token) { + try { + await request("/api/users/logout", { + method: "POST", + body: JSON.stringify({ accessToken: token }), + }); + } catch (error) { + console.error("Logout error:", error); + } + } + + await AsyncStorage.removeItem(ACCESS_TOKEN_KEY); + await AsyncStorage.removeItem(REFRESH_TOKEN_KEY); + await AsyncStorage.removeItem("userId"); + await AsyncStorage.removeItem("userName"); + await AsyncStorage.removeItem("membershipType"); + await AsyncStorage.removeItem("chatbot_tokens"); + }, + + /** + * 토큰 재발급 + * 만료된 accessToken을 refreshToken으로 새로 발급받음 + * @returns 새로 발급된 토큰 정보 + */ + refreshToken: async (): Promise<{ + success: boolean; + message: string; + accessToken?: string; + tokenType?: string; + expiresIn?: number; + }> => { + const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY); + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + const response = await request<{ + success: boolean; + message: string; + accessToken?: string; + tokenType?: string; + expiresIn?: number; + }>("/api/users/refresh-token", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }); + + if (response.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); + + const payload = decodeJWT(response.accessToken); + if (payload && payload.userPk) { + await AsyncStorage.setItem("userId", String(payload.userPk)); + console.log("[AUTH] userId 재저장 완료 (토큰 갱신):", payload.userPk); + } + } + + return response; + }, + + /** + * 현재 저장된 토큰 가져오기 + * @returns accessToken 또는 null + */ + getAccessToken: async (): Promise => { + return await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + }, + + /** + * 로그인 상태 확인 + * @returns 토큰 존재 여부 (true = 로그인됨, false = 비로그인) + */ + isAuthenticated: async (): Promise => { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + return !!token; + }, + + /** + * 사용자 프로필 조회 + */ + getProfile: async (): Promise<{ + id: number; + userId: string; + name: string; + email: string; + birthDate: string; + height: number; + weight: number; + weightGoal: number; + gender: "M" | "F"; + membershipType: string; + healthGoal: string; + workoutDaysPerWeek?: string; + lastLoginAt: string; + createdAt: string; + mealRecommendTokens?: number; + workoutRecommendTokens?: number; + chatbotTokens?: number; + mealRecommendLastReset?: string; + workoutRecommendLastReset?: string; + chatbotLastReset?: string; + }> => { + return request<{ + id: number; + userId: string; + name: string; + email: string; + birthDate: string; + height: number; + weight: number; + weightGoal: number; + gender: "M" | "F"; + membershipType: string; + healthGoal: string; + workoutDaysPerWeek?: string; + lastLoginAt: string; + createdAt: string; + mealRecommendTokens?: number; + workoutRecommendTokens?: number; + chatbotTokens?: number; + mealRecommendLastReset?: string; + workoutRecommendLastReset?: string; + chatbotLastReset?: string; + }>("/api/profile", { + method: "GET", + }); + }, + + /** + * 사용자 프로필 수정 + */ + updateProfile: async (profileData: { + name?: string; + height?: number; + weight?: number; + gender?: "M" | "F"; + healthGoal?: string; + workoutDaysPerWeek?: string; + weightGoal?: number; + }): Promise<{ + success: boolean; + message: string; + profile?: { + id: number; + userId: string; + name: string; + email: string; + birthDate: string; + height: number; + weight: number; + gender: "M" | "F"; + membershipType: string; + healthGoal: string; + workoutDaysPerWeek?: string; + weightGoal: number; + lastLoginAt: string; + createdAt: string; + }; + }> => { + return request<{ + success: boolean; + message: string; + profile?: any; + }>("/api/profile", { + method: "PUT", + body: JSON.stringify(profileData), + }); + }, + + /** + * 로그인 상태에서 비밀번호 변경 + */ + changePasswordLoggedIn: async ( + currentPassword: string, + newPassword: string, + newPasswordConfirm: string + ): Promise<{ + success: boolean; + message: string; + }> => { + return request<{ + success: boolean; + message: string; + }>("/api/profile/password", { + method: "PUT", + body: JSON.stringify({ + currentPassword, + newPassword, + newPasswordConfirm, + }), + }); + }, + + /** + * 회원탈퇴 + */ + deleteAccount: async ( + password: string, + reason: string + ): Promise<{ + success: boolean; + message: string; + }> => { + try { + const response = await request<{ + success: boolean; + message: string; + }>("/api/profile", { + method: "DELETE", + body: JSON.stringify({ + password, + reason, + }), + }); + + if (response.success) { + await AsyncStorage.removeItem(ACCESS_TOKEN_KEY); + await AsyncStorage.removeItem(REFRESH_TOKEN_KEY); + await AsyncStorage.removeItem("userId"); + await AsyncStorage.removeItem("userName"); + await AsyncStorage.removeItem("membershipType"); + await AsyncStorage.removeItem("chatbot_tokens"); + } + + return response; + } catch (error: any) { + console.error("회원탈퇴 에러:", error); + throw error; + } + }, + + // ======================================== + // ✅ 멤버십 관련 API (신규 추가) + // ======================================== + + /** + * 토큰 초기화 (테스트용) + * 모든 토큰을 기본값으로 초기화 + * - 챗봇 토큰: 3개 + * - 식단 추천 토큰: 1개 + * - 운동 추천 토큰: 1개 + */ + resetTokens: async (): Promise => { + try { + const response = await request<{ message: string }>( + "/api/users/tokens/reset", + { + method: "POST", + } + ); + + // 로컬 스토리지도 초기화 + await AsyncStorage.setItem("chatbot_tokens", "3"); + + console.log("[AUTH] 토큰 초기화 완료:", response); + return response.message || "토큰이 기본값으로 초기화되었습니다."; + } catch (error: any) { + console.error("토큰 초기화 에러:", error); + throw error; + } + }, + + /** + * 프리미엄으로 업그레이드 + * 무료 → 프리미엄으로 변경 + */ + upgradeToPremium: async (): Promise => { + try { + const response = await request<{ message: string }>( + "/api/users/membership/premium", + { + method: "POST", + } + ); + + // AsyncStorage도 업데이트 + await AsyncStorage.setItem("membershipType", "PREMIUM"); + console.log("[AUTH] 프리미엄 업그레이드 완료:", response); + + return response.message || "멤버십이 프리미엄으로 변경되었습니다."; + } catch (error: any) { + console.error("프리미엄 업그레이드 에러:", error); + throw error; + } + }, + + /** + * 무료로 다운그레이드 + * 프리미엄 → 무료로 변경 + */ + downgradeToFree: async (): Promise => { + try { + const response = await request<{ message: string }>( + "/api/users/membership/free", + { + method: "POST", + } + ); + + // AsyncStorage도 업데이트 + await AsyncStorage.setItem("membershipType", "FREE"); + await AsyncStorage.setItem("chatbot_tokens", "3"); + console.log("[AUTH] 무료 플랜 전환 완료:", response); + + return response.message || "멤버십이 무료 플랜으로 변경되었습니다."; + } catch (error: any) { + console.error("무료 플랜 전환 에러:", error); + throw error; + } + }, + + /** + * 멤버십 토글 (테스트용) + * 현재 상태를 확인하고 반대로 변경 + * FREE ↔ PREMIUM + */ + toggleMembership: async (): Promise<{ + newType: "FREE" | "PREMIUM"; + message: string; + }> => { + try { + // 현재 멤버십 타입 확인 + const currentType = await AsyncStorage.getItem("membershipType"); + + let message: string; + let newType: "FREE" | "PREMIUM"; + + if (currentType === "PREMIUM") { + // 프리미엄 → 무료 + message = await authAPI.downgradeToFree(); + newType = "FREE"; + } else { + // 무료 → 프리미엄 + message = await authAPI.upgradeToPremium(); + newType = "PREMIUM"; + } + + console.log("[AUTH] 멤버십 토글 완료:", currentType, "→", newType); + return { newType, message }; + } catch (error: any) { + console.error("멤버십 토글 에러:", error); + throw error; + } + }, + + /** + * AI 식단 추천 생성 + * @returns 추천된 식단 계획 + */ + generateMealPlan: async (): Promise<{ + id: number; + planName: string; + description: string; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + recommendationReason: string; + isSaved: boolean; + meals: Array<{ + id: number; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK" | "OTHER"; + mealTypeName: string; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + foods: Array<{ + id: number; + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; + }>; + }>; + createdAt: string; + }> => { + return request("/api/recommended-meals/generate", { + method: "POST", + }); + }, + + /** + * 추천 식단 저장 + * @param planId 저장할 식단 계획 ID + * @returns 저장 결과 및 저장된 식단 정보 + */ + saveMealPlan: async ( + planId: number + ): Promise<{ + success: boolean; + message: string; + plan: { + id: number; + planName: string; + isSaved: boolean; + }; + }> => { + return request<{ + success: boolean; + message: string; + plan: any; + }>(`/api/recommended-meals/${planId}/save`, { + method: "POST", + }); + }, + + /** + * 저장된 식단 목록 조회 + * @returns 저장된 식단 목록 + */ + getSavedMealPlans: async (): Promise> => { + return request>("/api/recommended-meals/saved", { + method: "GET", + }); + }, + + /** + * 저장된 식단 삭제 + * @param planId 삭제할 식단 ID + * @returns 삭제 결과 + */ + deleteMealPlan: async ( + planId: number + ): Promise<{ + success: boolean; + message: string; + }> => { + return request<{ + success: boolean; + message: string; + }>(`/api/recommended-meals/${planId}`, { + method: "DELETE", + }); + }, + + /** + * 카카오 로그인 (OAuth 인증 코드로 로그인) + * @param code 카카오 OAuth 인증 코드 + * @returns 로그인 결과 및 토큰 + */ + kakaoLogin: async ( + code: string + ): Promise<{ + accessToken: string; + refreshToken: string; + userId: number; + nickname: string; + profileImageUrl: string; + newUser: boolean; + onboarded: boolean; + }> => { + try { + const response = await request<{ + accessToken: string; + refreshToken: string; + userId: number; + nickname: string; + profileImageUrl: string; + newUser: boolean; + onboarded: boolean; + }>("/api/auth/kakao/login", { + method: "POST", + body: JSON.stringify({ code }), + }); + + if (response.accessToken) { + await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); + + // userId 저장 + if (response.userId) { + await AsyncStorage.setItem("userId", String(response.userId)); + console.log("[AUTH] 카카오 로그인 - userId 저장 완료:", response.userId); + } + + // JWT에서도 userId 추출 시도 + const payload = decodeJWT(response.accessToken); + if (payload && payload.userPk && !response.userId) { + await AsyncStorage.setItem("userId", String(payload.userPk)); + } + } + + if (response.refreshToken) { + await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.refreshToken); + } + + // membershipType은 기본값으로 설정 (백엔드 응답에 없음) + await AsyncStorage.setItem("membershipType", "FREE"); + + return response; + } catch (error: any) { + console.error("[AUTH] 카카오 로그인 실패:", error); + throw error; + } + }, + + /** + * 카카오 로그인 URL 가져오기 + * @returns 카카오 로그인 URL + */ + getKakaoLoginUrl: async (): Promise<{ url: string }> => { + return request<{ url: string }>("/api/auth/kakao/login-url", { + method: "GET", + }); + }, + + /** + * 카카오 로그아웃 + */ + kakaoLogout: async (): Promise<{ message: string }> => { + try { + const response = await request<{ message: string }>("/api/auth/kakao/logout", { + method: "POST", + }); + return response; + } catch (error: any) { + console.error("[AUTH] 카카오 로그아웃 실패:", error); + throw error; + } + }, + + /** + * 카카오 연결 해제 + */ + kakaoUnlink: async (): Promise<{ message: string }> => { + try { + const response = await request<{ message: string }>("/api/auth/kakao/unlink", { + method: "DELETE", + }); + return response; + } catch (error: any) { + console.error("[AUTH] 카카오 연결 해제 실패:", error); + throw error; + } + }, + + /** + * 카카오 로그인 후 온보딩 데이터 제출 + * @param onboardingData 온보딩 정보 + * @returns 성공 여부 + */ + submitKakaoOnboarding: async (onboardingData: { + gender: "M" | "F"; + height: number; + weight: number; + weightGoal: number; + healthGoal: string; + workoutDaysPerWeek: string; + experienceLevel?: string; + fitnessConcerns?: string; + }): Promise<{ success: boolean; message: string }> => { + try { + const response = await request<{ success: boolean; message: string }>( + "/api/auth/kakao/onboarding", + { + method: "POST", + body: JSON.stringify(onboardingData), + } + ); + return response; + } catch (error: any) { + console.error("[AUTH] 카카오 온보딩 데이터 제출 실패:", error); + throw error; + } + }, + + /** + * 온보딩 데이터 제출 (신체정보 수기입력) + * @param onboardingData 온보딩 정보 + * @returns 성공 여부 + */ + submitOnboarding: async (onboardingData: { + gender: "M" | "F"; + height: number; + weight: number; + birthDate: string; + weightGoal: number; + healthGoal: string; + workoutDaysPerWeek: string; + experienceLevel?: string; + fitnessConcerns?: string; + }): Promise<{ success: boolean; message: string }> => { + try { + const response = await request<{ success: boolean; message: string }>( + "/api/users/onboarding", + { + method: "POST", + body: JSON.stringify(onboardingData), + } + ); + return response; + } catch (error: any) { + console.error("[AUTH] 온보딩 데이터 제출 실패:", error); + throw error; + } + }, +}; diff --git a/src/services/myPlanAPI.ts b/src/services/myPlanAPI.ts index f028736..08c1c72 100644 --- a/src/services/myPlanAPI.ts +++ b/src/services/myPlanAPI.ts @@ -1,61 +1,61 @@ -import { request } from "./apiConfig"; - -export interface SubscriptionResponse { - hasActiveSubscription: boolean; - status: string; - planCode: string | null; - startedAt: string | null; - expiredAt: string | null; - canceledAt: string | null; - provider: string | null; - providerCustomerId: string | null; - providerSubscriptionId: string | null; -} - -// ✅ 취소 응답 타입 정의 (서버가 주는 JSON 구조에 맞춤) -export interface CancelSubscriptionResponse { - success: boolean; - message: string; - subscription: SubscriptionResponse; // 취소된 후의 상태 정보 -} - -export const myPlanAPI = { - /** - * 내 구독 정보 조회 - * GET /api/subscriptions/me - */ - getMySubscription: async (): Promise => { - try { - const response = await request( - "/api/subscriptions/me", - { method: "GET" } - ); - return response; - } catch (error: any) { - throw new Error(error.message || "구독 정보를 불러오는데 실패했습니다."); - } - }, - - /** - * ✅ 구독 취소 - * POST /api/subscriptions/cancel - */ - cancelSubscription: async (): Promise => { - try { - console.log("🚫 구독 취소 요청"); - - const response = await request( - "/api/subscriptions/cancel", - { - method: "POST", - } - ); - - console.log("✅ 구독 취소 완료:", response.message); - return response; - } catch (error: any) { - console.error("❌ 구독 취소 실패:", error); - throw new Error(error.message || "구독 취소에 실패했습니다."); - } - }, -}; +import { request } from "./apiConfig"; + +export interface SubscriptionResponse { + hasActiveSubscription: boolean; + status: string; + planCode: string | null; + startedAt: string | null; + expiredAt: string | null; + canceledAt: string | null; + provider: string | null; + providerCustomerId: string | null; + providerSubscriptionId: string | null; +} + +// ✅ 취소 응답 타입 정의 (서버가 주는 JSON 구조에 맞춤) +export interface CancelSubscriptionResponse { + success: boolean; + message: string; + subscription: SubscriptionResponse; // 취소된 후의 상태 정보 +} + +export const myPlanAPI = { + /** + * 내 구독 정보 조회 + * GET /api/subscriptions/me + */ + getMySubscription: async (): Promise => { + try { + const response = await request( + "/api/subscriptions/me", + { method: "GET" } + ); + return response; + } catch (error: any) { + throw new Error(error.message || "구독 정보를 불러오는데 실패했습니다."); + } + }, + + /** + * ✅ 구독 취소 + * POST /api/subscriptions/cancel + */ + cancelSubscription: async (): Promise => { + try { + console.log("🚫 구독 취소 요청"); + + const response = await request( + "/api/subscriptions/cancel", + { + method: "POST", + } + ); + + console.log("✅ 구독 취소 완료:", response.message); + return response; + } catch (error: any) { + console.error("❌ 구독 취소 실패:", error); + throw new Error(error.message || "구독 취소에 실패했습니다."); + } + }, +}; diff --git a/src/services/recommendedExerciseAPI.ts b/src/services/recommendedExerciseAPI.ts index 10b8fb5..07f14f4 100644 --- a/src/services/recommendedExerciseAPI.ts +++ b/src/services/recommendedExerciseAPI.ts @@ -1,201 +1,228 @@ -import { request } from "./apiConfig"; - -/** - * 운동 루틴 추천 API - */ -export const recommendedExerciseAPI = { - /** - * 0. 프로필 조회 - */ - getProfile: async (): Promise => { - try { - console.log("👤 프로필 조회"); - - const response = await request("/api/profile", { - method: "GET", - }); - - console.log("✅ 프로필 조회 성공"); - return response; - } catch (error: any) { - console.error("❌ 프로필 조회 실패:", error); - throw error; - } - }, - - /** - * 1. 일일 운동 플랜 생성 - * @param baseDate - 기준 날짜 (yyyy-MM-dd 형식) - * @param requestBody - 운동 추천 요청 바디 - * @returns 생성된 운동 플랜 정보 - * - * @example - * generateExercisePlan("2025-12-02", { - * experienceLevel: "INTERMEDIATE", - * environment: "gym", - * availableEquipment: ["덤벨", "머신"], - * likeMuscles: ["가슴", "어깨"], - * healthConditions: ["허리"], - * targetTimeMin: 60 - * }) - */ - generateExercisePlan: async ( - baseDate?: string, - requestBody?: { - experienceLevel: string; - environment: string; - availableEquipment: string[]; - likeMuscles: string[]; - healthConditions: string[]; - targetTimeMin: number; - } - ): Promise => { - try { - // baseDate가 없으면 오늘 날짜 사용 - const date = baseDate || new Date().toISOString().split("T")[0]; - - console.log("💪 운동 플랜 생성 요청"); - console.log("📅 baseDate:", date); - console.log("📦 requestBody:", JSON.stringify(requestBody, null, 2)); - - const response = await request( - `/api/exercise-recommendations/generate/daily?baseDate=${date}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: requestBody ? JSON.stringify(requestBody) : undefined, - } - ); - - console.log("✅ 운동 플랜 생성 성공:", JSON.stringify(response, null, 2)); - return response; - } catch (error: any) { - console.error("❌ 운동 플랜 생성 실패:", error); - - if (error.status === 401) { - throw new Error("로그인이 필요합니다. 다시 로그인해주세요."); - } - - // 토큰 부족 예외 처리 - if (error.code === "NO_WORKOUT_TOKENS") { - throw error; // 원본 에러 그대로 전달 - } - - throw error; - } - }, - - /** - * 2. 운동 플랜 저장 - * @param planId - 저장할 플랜 ID - */ - saveExercisePlan: async (planId: number): Promise => { - try { - console.log("💾 운동 플랜 저장 요청:", planId); - - const response = await request( - `/api/exercise-recommendations/${planId}/save`, - { - method: "POST", - } - ); - - console.log("✅ 운동 플랜 저장 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 운동 플랜 저장 실패:", error); - - if (error.status === 401) { - throw new Error("인증이 필요합니다. 다시 로그인해주세요."); - } - - if (error.status === 404) { - throw new Error("저장하려는 플랜을 찾을 수 없습니다."); - } - - throw error; - } - }, - - /** - * 3. 저장된 운동 플랜 목록 조회 - */ - getSavedExercisePlans: async (): Promise => { - try { - console.log("📋 저장된 운동 플랜 목록 조회"); - - const response = await request(`/api/exercise-recommendations`, { - method: "GET", - }); - - console.log("✅ 저장된 플랜 조회 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 저장된 플랜 조회 실패:", error); - - if (error.status === 401) { - throw new Error("로그인이 필요합니다."); - } - - throw error; - } - }, - - /** - * 4. 저장된 운동 플랜 상세 조회 - * @param planId - 조회할 플랜 ID - */ - getSavedExercisePlanDetail: async (planId: number): Promise => { - try { - console.log("📋 운동 플랜 상세 조회:", planId); - - const response = await request( - `/api/exercise-recommendations/${planId}`, - { - method: "GET", - } - ); - - console.log("✅ 플랜 상세 조회 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 플랜 상세 조회 실패:", error); - - if (error.status === 401) { - throw new Error("로그인이 필요합니다."); - } - - throw error; - } - }, - - /** - * 5. 운동 플랜 삭제 - * @param planId - 삭제할 플랜 ID - */ - deleteExercisePlan: async (planId: number): Promise => { - try { - console.log("🗑️ 운동 플랜 삭제:", planId); - - const response = await request( - `/api/exercise-recommendations/${planId}`, - { - method: "DELETE", - } - ); - - console.log("✅ 플랜 삭제 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 플랜 삭제 실패:", error); - - if (error.status === 401) { - throw new Error("로그인이 필요합니다."); - } - - throw error; - } - }, -}; +import { request } from "./apiConfig"; + +/** + * 운동 루틴 추천 API + */ +export const recommendedExerciseAPI = { + /** + * 0. 프로필 조회 + */ + getProfile: async (): Promise => { + try { + console.log("👤 프로필 조회"); + + const response = await request("/api/profile", { + method: "GET", + }); + + console.log("✅ 프로필 조회 성공"); + return response; + } catch (error: any) { + console.error("❌ 프로필 조회 실패:", error); + throw error; + } + }, + + /** + * 1. 일일 운동 플랜 생성 + * @param baseDate - 기준 날짜 (yyyy-MM-dd 형식) + * @param requestBody - 운동 추천 요청 바디 + * @returns 생성된 운동 플랜 정보 + * + * @example + * generateExercisePlan("2025-12-02", { + * experienceLevel: "INTERMEDIATE", + * environment: "gym", + * availableEquipment: ["덤벨", "머신"], + * likeMuscles: ["가슴", "어깨"], + * healthConditions: ["허리"], + * targetTimeMin: 60 + * }) + */ + generateExercisePlan: async ( + baseDate?: string, + requestBody?: { + experienceLevel: string; + environment: string; + availableEquipment: string[]; + likeMuscles: string[]; + healthConditions: string[]; + targetTimeMin: number; + } + ): Promise => { + try { + // baseDate가 없으면 오늘 날짜 사용 + const date = baseDate || new Date().toISOString().split("T")[0]; + + console.log("💪 운동 플랜 생성 요청"); + console.log("📅 baseDate:", date); + console.log("📦 requestBody:", JSON.stringify(requestBody, null, 2)); + + const response = await request( + `/api/exercise-recommendations/generate/daily?baseDate=${date}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: requestBody ? JSON.stringify(requestBody) : undefined, + } + ); + + console.log("✅ 운동 플랜 생성 성공:", JSON.stringify(response, null, 2)); + return response; + } catch (error: any) { + console.error("❌ 운동 플랜 생성 실패:", error); + + if (error.status === 401) { + throw new Error("로그인이 필요합니다. 다시 로그인해주세요."); + } + + // 토큰 부족 예외 처리 + if (error.code === "NO_WORKOUT_TOKENS") { + throw error; // 원본 에러 그대로 전달 + } + + throw error; + } + }, + + /** + * 2. 운동 플랜 저장 + * @param planId - 저장할 플랜 ID + */ + saveExercisePlan: async (planId: number): Promise => { + try { + console.log("💾 운동 플랜 저장 요청:", planId); + + const response = await request( + `/api/exercise-recommendations/${planId}/save`, + { + method: "POST", + } + ); + + console.log("✅ 운동 플랜 저장 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 운동 플랜 저장 실패:", error); + + if (error.status === 401) { + throw new Error("인증이 필요합니다. 다시 로그인해주세요."); + } + + if (error.status === 404) { + throw new Error("저장하려는 플랜을 찾을 수 없습니다."); + } + + throw error; + } + }, + + /** + * 3. 저장된 운동 플랜 목록 조회 + */ + getSavedExercisePlans: async (): Promise => { + try { + console.log("📋 저장된 운동 플랜 목록 조회"); + + const response = await request(`/api/exercise-recommendations`, { + method: "GET", + }); + + console.log("✅ 저장된 플랜 조회 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 저장된 플랜 조회 실패:", error); + + if (error.status === 401) { + throw new Error("로그인이 필요합니다."); + } + + throw error; + } + }, + + /** + * 4. 저장된 운동 플랜 상세 조회 + * @param planId - 조회할 플랜 ID + */ + getSavedExercisePlanDetail: async (planId: number): Promise => { + try { + console.log("📋 운동 플랜 상세 조회:", planId); + + const response = await request( + `/api/exercise-recommendations/${planId}`, + { + method: "GET", + } + ); + + console.log("✅ 플랜 상세 조회 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 플랜 상세 조회 실패:", error); + + if (error.status === 401) { + throw new Error("로그인이 필요합니다."); + } + + throw error; + } + }, + + /** + * 5. 운동 플랜 삭제 + * @param planId - 삭제할 플랜 ID + */ + deleteExercisePlan: async (planId: number): Promise => { + try { + console.log("🗑️ 운동 플랜 삭제:", planId); + + const response = await request( + `/api/exercise-recommendations/${planId}`, + { + method: "DELETE", + } + ); + + console.log("✅ 플랜 삭제 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 플랜 삭제 실패:", error); + + if (error.status === 401) { + throw new Error("로그인이 필요합니다."); + } + + throw error; + } + }, + + /** + * 6. 추천받은 운동 목록 조회 + */ + getRecommendedExercises: async (): Promise => { + try { + console.log("📋 추천받은 운동 목록 조회"); + + const response = await request( + `/api/exercise-recommendations/recommended-exercises`, + { + method: "GET", + } + ); + + console.log("✅ 추천받은 운동 조회 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 추천받은 운동 조회 실패:", error); + + if (error.status === 401) { + throw new Error("로그인이 필요합니다."); + } + + throw error; + } + }, +}; diff --git a/src/services/recommendedMealAPI.ts b/src/services/recommendedMealAPI.ts index 86bd3b2..3c78b37 100644 --- a/src/services/recommendedMealAPI.ts +++ b/src/services/recommendedMealAPI.ts @@ -1,461 +1,461 @@ -// src/services/recommendedMealAPI.ts -import { request } from "./apiConfig"; - -const formatDateToString = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -/** - * 식단 추천 API (임시 식단 기반) - */ -export const recommendedMealAPI = { - /** - * ✅ 1) 임시 식단 생성 + 조회 (7일치) - * @param mealsPerDay - 하루 끼니 수 (1~3, 기본값 3) - */ - getWeeklyMealPlan: async (mealsPerDay: number = 3) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 180000); // 3분 - - try { - console.log("🍽️ 임시 식단 생성 시작 (7일치)"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - console.log("⏱️ 타임아웃: 3분"); - - // Step 1: 임시 식단 생성 (끼니 수 포함) - const createResponse = await request<{ tempBundleId: string }>( - "/api/temp-meals/weekly", - { - method: "POST", - body: JSON.stringify({ mealsPerDay }), - signal: controller.signal, - } - ); - - console.log("✅ 임시 식단 생성 완료:", createResponse); - - // Step 2: 임시 식단 조회 - const response = await request>( - "/api/temp-meals/weekly", - { - method: "GET", - signal: controller.signal, - } - ); - - clearTimeout(timeoutId); - console.log("✅ 임시 식단 조회 완료:", response.length, "일"); - return response; - } catch (error: any) { - clearTimeout(timeoutId); - - if (error.name === "AbortError") { - console.error("⏱️ 타임아웃 발생 (3분 초과)"); - throw new Error( - "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." - ); - } - - console.error("❌ 임시 식단 생성/조회 실패:", error); - throw new Error(error.message || "식단 생성에 실패했습니다."); - } - }, - - getDailyMealPlan: async (mealsPerDay: number = 3) => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, 120000); // 2분 - - try { - console.log("🍽️ 임시 식단 생성 시작 (1일치)"); - console.log(`📊 끼니 수: ${mealsPerDay}끼`); - console.log("⏱️ 타임아웃: 2분"); - - // Step 1: 1일치 임시 식단 생성 (daily 엔드포인트 사용) - const createResponse = await request<{ - message?: string; - tempBundleId?: string; - }>("/api/temp-meals/daily", { - method: "POST", - body: JSON.stringify({ mealsPerDay }), - signal: controller.signal, - }); - - console.log("✅ 1일 임시 식단 생성 완료:", createResponse); - - // ✅ Step 2: 임시 식단 조회 - const response = await request>( - "/api/temp-meals/weekly", // ✅ daily가 아니라 weekly! - { - method: "GET", - signal: controller.signal, - } - ); - - clearTimeout(timeoutId); - console.log("✅ 임시 식단 조회 완료:", response.length, "일"); - return response; - } catch (error: any) { - clearTimeout(timeoutId); - - if (error.name === "AbortError") { - console.error("⏱️ 타임아웃 발생 (2분 초과)"); - throw new Error( - "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." - ); - } - - console.error("❌ 1일 식단 생성/조회 실패:", error); - throw new Error(error.message || "식단 생성에 실패했습니다."); - } - }, - - /** - * ✅ 2) 임시 식단 저장 (DB에 확정) - */ - saveTempMealPlan: async () => { - try { - console.log("💾 임시 식단 commit 요청"); - - const response = await request<{ message: string }>( - "/api/temp-meals/weekly/commit", - { - method: "POST", - } - ); - - console.log("✅ 임시 식단 저장 완료:", response.message); - return { - success: true, - message: response.message || "식단이 저장되었습니다.", - }; - } catch (error: any) { - console.error("❌ 임시 식단 저장 실패:", error); - throw new Error(error.message || "식단 저장에 실패했습니다."); - } - }, - - /** - * 3) 저장된 식단 목록 조회 (번들 단위) - */ - getSavedMealPlans: async () => { - try { - console.log("📋 저장된 식단 목록 조회"); - - const response = await request<{ - totalCount: number; - plans: SavedMealPlanSummary[]; - }>("/api/recommended-meals/saved", { - method: "GET", - }); - - console.log("✅ 저장된 식단:", response.totalCount, "개"); - return response.plans || []; - } catch (error: any) { - console.error("❌ 저장된 식단 조회 실패:", error); - return []; - } - }, - - /** - * ✅ 4) 번들 상세 조회 (DietScreen용 - 평탄화) - */ - getSavedMealPlansByBundle: async (bundleId: string) => { - try { - console.log("🔍 번들 상세 조회 (DietScreen용):", bundleId); - - const response = await request>( - `/api/recommended-meals/bundles/${bundleId}`, - { - method: "GET", - } - ); - - console.log("✅ 서버 응답:", response.length, "개 플랜"); - - // ✅ 첫 번째 플랜의 날짜를 기준 날짜로 사용 - const baseDateStr = - response[0]?.planDate || formatDateToString(new Date()); - const baseDate = new Date(baseDateStr); - - console.log("📅 기준 날짜:", baseDateStr); - - // ✅ DietScreen이 기대하는 형식으로 변환 - const flattenedMeals: any[] = []; - - response.forEach((plan) => { - // ✅ bundleDay를 사용하여 실제 날짜 계산 - const actualDate = new Date(baseDate); - actualDate.setDate(baseDate.getDate() + (plan.bundleDay - 1)); - const actualDateStr = formatDateToString(actualDate); - - console.log(`📆 ${plan.bundleDay}일차 → ${actualDateStr}`); - - // ✅ SNACK을 BREAKFAST/LUNCH/DINNER로 변환 - const meals = plan.meals || []; - const breakfast = meals.find((m: any) => m.mealType === "BREAKFAST"); - const lunch = meals.find((m: any) => m.mealType === "LUNCH"); - const dinner = meals.find((m: any) => m.mealType === "DINNER"); - - // BREAKFAST, LUNCH, DINNER가 없고 SNACK만 있는 경우 변환 - if (!breakfast && !lunch && !dinner) { - const snacks = meals.filter((m: any) => m.mealType === "SNACK"); - - if (snacks.length >= 1) { - flattenedMeals.push({ - ...snacks[0], - id: snacks[0].id, - planId: plan.id, - planName: plan.planName, - bundleId: plan.bundleId, - bundleDay: plan.bundleDay, - mealType: "BREAKFAST", - mealTypeName: "아침", - targetDate: actualDateStr, - originalPlanDate: plan.planDate, - createdAt: plan.createdAt, - }); - } - if (snacks.length >= 2) { - flattenedMeals.push({ - ...snacks[1], - id: snacks[1].id, - planId: plan.id, - planName: plan.planName, - bundleId: plan.bundleId, - bundleDay: plan.bundleDay, - mealType: "LUNCH", - mealTypeName: "점심", - targetDate: actualDateStr, - originalPlanDate: plan.planDate, - createdAt: plan.createdAt, - }); - } - if (snacks.length >= 3) { - flattenedMeals.push({ - ...snacks[2], - id: snacks[2].id, - planId: plan.id, - planName: plan.planName, - bundleId: plan.bundleId, - bundleDay: plan.bundleDay, - mealType: "DINNER", - mealTypeName: "저녁", - targetDate: actualDateStr, - originalPlanDate: plan.planDate, - createdAt: plan.createdAt, - }); - } - } else { - // BREAKFAST, LUNCH, DINNER가 있는 경우 그대로 사용 - plan.meals.forEach((meal: any) => { - flattenedMeals.push({ - id: meal.id, - planId: plan.id, - planName: plan.planName, - bundleId: plan.bundleId, - bundleDay: plan.bundleDay, - mealType: meal.mealType, - mealTypeName: meal.mealTypeName, - totalCalories: meal.totalCalories, - totalCarbs: meal.totalCarbs, - totalProtein: meal.totalProtein, - totalFat: meal.totalFat, - foods: meal.foods, - targetDate: actualDateStr, - originalPlanDate: plan.planDate, - createdAt: plan.createdAt, - }); - }); - } - }); - - console.log("✅ 평탄화 완료:", flattenedMeals.length, "개 끼니"); - - // ✅ 날짜별 분포 로그 - const dateDistribution = flattenedMeals.reduce((acc, meal) => { - acc[meal.targetDate] = (acc[meal.targetDate] || 0) + 1; - return acc; - }, {} as Record); - - console.log("📊 날짜별 분포:", dateDistribution); - - return flattenedMeals; - } catch (error: any) { - console.error("❌ 번들 상세 조회 실패:", error); - throw new Error(error.message || "번들 상세 조회에 실패했습니다."); - } - }, - - /** - * 4-1) 번들 상세 조회 (원본 구조) - */ - getBundleDetail: async (bundleId: string) => { - try { - console.log("🔍 번들 상세 조회 (원본):", bundleId); - - const response = await request>( - `/api/recommended-meals/bundles/${bundleId}`, - { - method: "GET", - } - ); - - console.log("✅ 번들 상세:", response.length, "일"); - return response; - } catch (error: any) { - console.error("❌ 번들 상세 조회 실패:", error); - throw new Error(error.message || "번들 상세 조회에 실패했습니다."); - } - }, - - /** - * 5) 단일 식단 삭제 - */ - deleteMealPlan: async (planId: number) => { - try { - console.log("🗑️ 식단 삭제:", planId); - - const response = await request<{ - success: boolean; - message: string; - }>(`/api/recommended-meals/${planId}`, { - method: "DELETE", - }); - - console.log("✅ 식단 삭제 완료"); - return response; - } catch (error: any) { - console.error("❌ 식단 삭제 실패:", error); - throw new Error(error.message || "식단 삭제에 실패했습니다."); - } - }, - - /** - * 6) 번들 삭제 (7일치 전체) - */ - deleteBundle: async (bundleId: string) => { - try { - console.log("🗑️ 번들 삭제:", bundleId); - - const response = await request<{ - success: boolean; - message: string; - }>(`/api/recommended-meals/bundles/${bundleId}`, { - method: "DELETE", - }); - - console.log("✅ 번들 삭제 완료"); - return response; - } catch (error: any) { - console.error("❌ 번들 삭제 실패:", error); - throw new Error(error.message || "번들 삭제에 실패했습니다."); - } - }, - - /** - * 7) 일괄 삭제 - */ - deleteBulk: async (bundleIds: string[]) => { - try { - console.log("🗑️ 일괄 삭제:", bundleIds.length, "개"); - - const response = await request<{ - success: boolean; - message: string; - deletedCount: number; - }>("/api/recommended-meals/bundles/bulk-delete", { - method: "DELETE", - body: JSON.stringify({ bundleIds }), - }); - - console.log("✅ 일괄 삭제 완료:", response.deletedCount, "개"); - return response; - } catch (error: any) { - console.error("❌ 일괄 삭제 실패:", error); - throw new Error(error.message || "일괄 삭제에 실패했습니다."); - } - }, -}; - -/** - * 타입 정의 - */ -export interface TempDayMeal { - dayIndex: number; - meals: Array<{ - id: number; - mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - foods: Array<{ - id: number; - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; - }>; - }>; -} - -export interface Food { - id: number; - foodName: string; - servingSize: number; - calories: number; - carbs: number; - protein: number; - fat: number; - sodium?: number | null; - sugar?: number | null; -} - -export interface Meal { - id: number; - mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; - mealTypeName: string; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - foods: Food[]; -} - -export interface MealPlan { - id: number; - planName: string; - description: string; - totalCalories: number; - totalCarbs: number; - totalProtein: number; - totalFat: number; - recommendationReason: string | null; - isSaved: boolean; - meals: Meal[]; - createdAt: string; - bundleId: string; - bundleDay: number; - planDate: string; -} - -export interface SavedMealPlanSummary { - id: number; - planName: string; - totalCalories: number; - mealCount: number; - isSaved: boolean; - createdAt: string; - bundleId: string; - bundleDay: number; - planDate: string; -} +// src/services/recommendedMealAPI.ts +import { request } from "./apiConfig"; + +const formatDateToString = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** + * 식단 추천 API (임시 식단 기반) + */ +export const recommendedMealAPI = { + /** + * ✅ 1) 임시 식단 생성 + 조회 (7일치) + * @param mealsPerDay - 하루 끼니 수 (1~3, 기본값 3) + */ + getWeeklyMealPlan: async (mealsPerDay: number = 3) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 180000); // 3분 + + try { + console.log("🍽️ 임시 식단 생성 시작 (7일치)"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + console.log("⏱️ 타임아웃: 3분"); + + // Step 1: 임시 식단 생성 (끼니 수 포함) + const createResponse = await request<{ tempBundleId: string }>( + "/api/temp-meals/weekly", + { + method: "POST", + body: JSON.stringify({ mealsPerDay }), + signal: controller.signal, + } + ); + + console.log("✅ 임시 식단 생성 완료:", createResponse); + + // Step 2: 임시 식단 조회 + const response = await request>( + "/api/temp-meals/weekly", + { + method: "GET", + signal: controller.signal, + } + ); + + clearTimeout(timeoutId); + console.log("✅ 임시 식단 조회 완료:", response.length, "일"); + return response; + } catch (error: any) { + clearTimeout(timeoutId); + + if (error.name === "AbortError") { + console.error("⏱️ 타임아웃 발생 (3분 초과)"); + throw new Error( + "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." + ); + } + + console.error("❌ 임시 식단 생성/조회 실패:", error); + throw new Error(error.message || "식단 생성에 실패했습니다."); + } + }, + + getDailyMealPlan: async (mealsPerDay: number = 3) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 120000); // 2분 + + try { + console.log("🍽️ 임시 식단 생성 시작 (1일치)"); + console.log(`📊 끼니 수: ${mealsPerDay}끼`); + console.log("⏱️ 타임아웃: 2분"); + + // Step 1: 1일치 임시 식단 생성 (daily 엔드포인트 사용) + const createResponse = await request<{ + message?: string; + tempBundleId?: string; + }>("/api/temp-meals/daily", { + method: "POST", + body: JSON.stringify({ mealsPerDay }), + signal: controller.signal, + }); + + console.log("✅ 1일 임시 식단 생성 완료:", createResponse); + + // ✅ Step 2: 임시 식단 조회 + const response = await request>( + "/api/temp-meals/weekly", // ✅ daily가 아니라 weekly! + { + method: "GET", + signal: controller.signal, + } + ); + + clearTimeout(timeoutId); + console.log("✅ 임시 식단 조회 완료:", response.length, "일"); + return response; + } catch (error: any) { + clearTimeout(timeoutId); + + if (error.name === "AbortError") { + console.error("⏱️ 타임아웃 발생 (2분 초과)"); + throw new Error( + "식단 생성 시간이 초과되었습니다.\n잠시 후 다시 시도해주세요." + ); + } + + console.error("❌ 1일 식단 생성/조회 실패:", error); + throw new Error(error.message || "식단 생성에 실패했습니다."); + } + }, + + /** + * ✅ 2) 임시 식단 저장 (DB에 확정) + */ + saveTempMealPlan: async () => { + try { + console.log("💾 임시 식단 commit 요청"); + + const response = await request<{ message: string }>( + "/api/temp-meals/weekly/commit", + { + method: "POST", + } + ); + + console.log("✅ 임시 식단 저장 완료:", response.message); + return { + success: true, + message: response.message || "식단이 저장되었습니다.", + }; + } catch (error: any) { + console.error("❌ 임시 식단 저장 실패:", error); + throw new Error(error.message || "식단 저장에 실패했습니다."); + } + }, + + /** + * 3) 저장된 식단 목록 조회 (번들 단위) + */ + getSavedMealPlans: async () => { + try { + console.log("📋 저장된 식단 목록 조회"); + + const response = await request<{ + totalCount: number; + plans: SavedMealPlanSummary[]; + }>("/api/recommended-meals/saved", { + method: "GET", + }); + + console.log("✅ 저장된 식단:", response.totalCount, "개"); + return response.plans || []; + } catch (error: any) { + console.error("❌ 저장된 식단 조회 실패:", error); + return []; + } + }, + + /** + * ✅ 4) 번들 상세 조회 (DietScreen용 - 평탄화) + */ + getSavedMealPlansByBundle: async (bundleId: string) => { + try { + console.log("🔍 번들 상세 조회 (DietScreen용):", bundleId); + + const response = await request>( + `/api/recommended-meals/bundles/${bundleId}`, + { + method: "GET", + } + ); + + console.log("✅ 서버 응답:", response.length, "개 플랜"); + + // ✅ 첫 번째 플랜의 날짜를 기준 날짜로 사용 + const baseDateStr = + response[0]?.planDate || formatDateToString(new Date()); + const baseDate = new Date(baseDateStr); + + console.log("📅 기준 날짜:", baseDateStr); + + // ✅ DietScreen이 기대하는 형식으로 변환 + const flattenedMeals: any[] = []; + + response.forEach((plan) => { + // ✅ bundleDay를 사용하여 실제 날짜 계산 + const actualDate = new Date(baseDate); + actualDate.setDate(baseDate.getDate() + (plan.bundleDay - 1)); + const actualDateStr = formatDateToString(actualDate); + + console.log(`📆 ${plan.bundleDay}일차 → ${actualDateStr}`); + + // ✅ SNACK을 BREAKFAST/LUNCH/DINNER로 변환 + const meals = plan.meals || []; + const breakfast = meals.find((m: any) => m.mealType === "BREAKFAST"); + const lunch = meals.find((m: any) => m.mealType === "LUNCH"); + const dinner = meals.find((m: any) => m.mealType === "DINNER"); + + // BREAKFAST, LUNCH, DINNER가 없고 SNACK만 있는 경우 변환 + if (!breakfast && !lunch && !dinner) { + const snacks = meals.filter((m: any) => m.mealType === "SNACK"); + + if (snacks.length >= 1) { + flattenedMeals.push({ + ...snacks[0], + id: snacks[0].id, + planId: plan.id, + planName: plan.planName, + bundleId: plan.bundleId, + bundleDay: plan.bundleDay, + mealType: "BREAKFAST", + mealTypeName: "아침", + targetDate: actualDateStr, + originalPlanDate: plan.planDate, + createdAt: plan.createdAt, + }); + } + if (snacks.length >= 2) { + flattenedMeals.push({ + ...snacks[1], + id: snacks[1].id, + planId: plan.id, + planName: plan.planName, + bundleId: plan.bundleId, + bundleDay: plan.bundleDay, + mealType: "LUNCH", + mealTypeName: "점심", + targetDate: actualDateStr, + originalPlanDate: plan.planDate, + createdAt: plan.createdAt, + }); + } + if (snacks.length >= 3) { + flattenedMeals.push({ + ...snacks[2], + id: snacks[2].id, + planId: plan.id, + planName: plan.planName, + bundleId: plan.bundleId, + bundleDay: plan.bundleDay, + mealType: "DINNER", + mealTypeName: "저녁", + targetDate: actualDateStr, + originalPlanDate: plan.planDate, + createdAt: plan.createdAt, + }); + } + } else { + // BREAKFAST, LUNCH, DINNER가 있는 경우 그대로 사용 + plan.meals.forEach((meal: any) => { + flattenedMeals.push({ + id: meal.id, + planId: plan.id, + planName: plan.planName, + bundleId: plan.bundleId, + bundleDay: plan.bundleDay, + mealType: meal.mealType, + mealTypeName: meal.mealTypeName, + totalCalories: meal.totalCalories, + totalCarbs: meal.totalCarbs, + totalProtein: meal.totalProtein, + totalFat: meal.totalFat, + foods: meal.foods, + targetDate: actualDateStr, + originalPlanDate: plan.planDate, + createdAt: plan.createdAt, + }); + }); + } + }); + + console.log("✅ 평탄화 완료:", flattenedMeals.length, "개 끼니"); + + // ✅ 날짜별 분포 로그 + const dateDistribution = flattenedMeals.reduce((acc, meal) => { + acc[meal.targetDate] = (acc[meal.targetDate] || 0) + 1; + return acc; + }, {} as Record); + + console.log("📊 날짜별 분포:", dateDistribution); + + return flattenedMeals; + } catch (error: any) { + console.error("❌ 번들 상세 조회 실패:", error); + throw new Error(error.message || "번들 상세 조회에 실패했습니다."); + } + }, + + /** + * 4-1) 번들 상세 조회 (원본 구조) + */ + getBundleDetail: async (bundleId: string) => { + try { + console.log("🔍 번들 상세 조회 (원본):", bundleId); + + const response = await request>( + `/api/recommended-meals/bundles/${bundleId}`, + { + method: "GET", + } + ); + + console.log("✅ 번들 상세:", response.length, "일"); + return response; + } catch (error: any) { + console.error("❌ 번들 상세 조회 실패:", error); + throw new Error(error.message || "번들 상세 조회에 실패했습니다."); + } + }, + + /** + * 5) 단일 식단 삭제 + */ + deleteMealPlan: async (planId: number) => { + try { + console.log("🗑️ 식단 삭제:", planId); + + const response = await request<{ + success: boolean; + message: string; + }>(`/api/recommended-meals/${planId}`, { + method: "DELETE", + }); + + console.log("✅ 식단 삭제 완료"); + return response; + } catch (error: any) { + console.error("❌ 식단 삭제 실패:", error); + throw new Error(error.message || "식단 삭제에 실패했습니다."); + } + }, + + /** + * 6) 번들 삭제 (7일치 전체) + */ + deleteBundle: async (bundleId: string) => { + try { + console.log("🗑️ 번들 삭제:", bundleId); + + const response = await request<{ + success: boolean; + message: string; + }>(`/api/recommended-meals/bundles/${bundleId}`, { + method: "DELETE", + }); + + console.log("✅ 번들 삭제 완료"); + return response; + } catch (error: any) { + console.error("❌ 번들 삭제 실패:", error); + throw new Error(error.message || "번들 삭제에 실패했습니다."); + } + }, + + /** + * 7) 일괄 삭제 + */ + deleteBulk: async (bundleIds: string[]) => { + try { + console.log("🗑️ 일괄 삭제:", bundleIds.length, "개"); + + const response = await request<{ + success: boolean; + message: string; + deletedCount: number; + }>("/api/recommended-meals/bundles/bulk-delete", { + method: "DELETE", + body: JSON.stringify({ bundleIds }), + }); + + console.log("✅ 일괄 삭제 완료:", response.deletedCount, "개"); + return response; + } catch (error: any) { + console.error("❌ 일괄 삭제 실패:", error); + throw new Error(error.message || "일괄 삭제에 실패했습니다."); + } + }, +}; + +/** + * 타입 정의 + */ +export interface TempDayMeal { + dayIndex: number; + meals: Array<{ + id: number; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + foods: Array<{ + id: number; + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; + }>; + }>; +} + +export interface Food { + id: number; + foodName: string; + servingSize: number; + calories: number; + carbs: number; + protein: number; + fat: number; + sodium?: number | null; + sugar?: number | null; +} + +export interface Meal { + id: number; + mealType: "BREAKFAST" | "LUNCH" | "DINNER" | "SNACK"; + mealTypeName: string; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + foods: Food[]; +} + +export interface MealPlan { + id: number; + planName: string; + description: string; + totalCalories: number; + totalCarbs: number; + totalProtein: number; + totalFat: number; + recommendationReason: string | null; + isSaved: boolean; + meals: Meal[]; + createdAt: string; + bundleId: string; + bundleDay: number; + planDate: string; +} + +export interface SavedMealPlanSummary { + id: number; + planName: string; + totalCalories: number; + mealCount: number; + isSaved: boolean; + createdAt: string; + bundleId: string; + bundleDay: number; + planDate: string; +} diff --git a/src/services/userPreferencesAPI.ts b/src/services/userPreferencesAPI.ts index c9490c3..e3fa638 100644 --- a/src/services/userPreferencesAPI.ts +++ b/src/services/userPreferencesAPI.ts @@ -1,133 +1,74 @@ -// src/services/userPreferencesAPI.ts -import { request } from "./apiConfig"; -import type { - UserPreferencesResponse, - AddDislikedFoodResponse, - RemoveDislikedFoodResponse, -} from "../types"; - -export const userPreferencesAPI = { - /** - * 사용자 전체 선호도 조회 - * GET /api/user/preferences - */ - getUserPreferences: async (): Promise => { - try { - console.log("🍽️ 사용자 선호도 조회"); - - const response = await request( - "/api/user/preferences", - { - method: "GET", - } - ); - - console.log("✅ 선호도 조회 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 선호도 조회 실패:", error); - throw new Error(error.message || "사용자 선호도 조회에 실패했습니다."); - } - }, - - /** - * 비선호 음식만 가져오기 (편의 함수) - */ - getDislikedFoods: async (): Promise => { - try { - const preferences = await userPreferencesAPI.getUserPreferences(); - return preferences.dislikedFoods || []; - } catch (error: any) { - console.error("❌ 비선호 음식 조회 실패:", error); - return []; - } - }, - - //Post 비선호 음식 추거ㅏ - addDislikedFood: async ( - foodName: string - ): Promise => { - try { - console.log("➕ 비선호 음식 추가:", foodName); - - const requestBody = { - foodName, - }; - - console.log("📤 요청 본문:", requestBody); - - const response = await request( - "/api/user/preferences/disliked", - { - method: "POST", - body: JSON.stringify(requestBody), - } - ); - - console.log("✅ 비선호 음식 추가 성공:", response); - return response; - } catch (error: any) { - console.error("❌ 비선호 음식 추가 실패:", error); - throw new Error(error.message || "비선호 음식 추가에 실패했습니다."); - } - }, - - /** - * 비선호 음식 여러 개 추가 (편의 함수) - */ - addDislikedFoods: async ( - currentList: string[], - newFoods: string[] - ): Promise<{ updatedList: string[] }> => { - try { - console.log("➕ 비선호 음식 여러 개 추가:", newFoods); - - let lastResponse: AddDislikedFoodResponse | null = null; - - for (const food of newFoods) { - lastResponse = await userPreferencesAPI.addDislikedFood(food); - } - - return { - updatedList: lastResponse?.dislikedFoods || [ - ...currentList, - ...newFoods, - ], - }; - } catch (error: any) { - console.error("❌ 비선호 음식 여러 개 추가 실패:", error); - throw error; - } - }, - - /** - * 비선호 음식 삭제 - * DELETE /api/user/preferences/disliked/{foodName} - */ - removeDislikedFood: async ( - currentList: string[], - foodName: string - ): Promise<{ updatedList: string[] }> => { - try { - console.log("🗑️ 비선호 음식 삭제:", foodName); - - const url = `/api/user/preferences/disliked/${encodeURIComponent( - foodName - )}`; - - const response = await request(url, { - method: "DELETE", - }); - - console.log("✅ 비선호 음식 삭제 성공:", response); - - return { - updatedList: - response.dislikedFoods || currentList.filter((f) => f !== foodName), - }; - } catch (error: any) { - console.error("❌ 비선호 음식 삭제 실패:", error); - throw new Error(error.message || "비선호 음식 삭제에 실패했습니다."); - } - }, -}; +// src/services/userPreferencesAPI.ts +import { request } from "./apiConfig"; +import type { UserPreferencesResponse } from "../types"; + +export const userPreferencesAPI = { + /** + * 사용자 전체 선호도 조회 + * GET /api/user/preferences + */ + getUserPreferences: async (): Promise => { + try { + console.log("🍽️ 사용자 선호도 조회"); + + const response = await request( + "/api/user/preferences", + { + method: "GET", + } + ); + + console.log("✅ 선호도 조회 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 선호도 조회 실패:", error); + throw new Error(error.message || "사용자 선호도 조회에 실패했습니다."); + } + }, + + /** + * 비선호 음식만 가져오기 (편의 함수) + */ + getDislikedFoods: async (): Promise => { + try { + const preferences = await userPreferencesAPI.getUserPreferences(); + return preferences.dislikedFoods || []; + } catch (error: any) { + console.error("❌ 비선호 음식 조회 실패:", error); + return []; + } + }, + + /** + * 금지 식단 전체 저장 (덮어쓰기) + * POST /api/user/preferences/disliked + * Body: { "dislikedFoods": ["땅콩", "우유", "새우"] } + */ + saveDislikedFoods: async ( + dislikedFoods: string[] + ): Promise<{ dislikedFoods: string[] }> => { + try { + console.log("💾 금지 식단 전체 저장:", dislikedFoods); + + const requestBody = { + dislikedFoods, + }; + + console.log("📤 요청 본문:", requestBody); + + const response = await request<{ dislikedFoods: string[] }>( + "/api/user/preferences/disliked", + { + method: "POST", + body: JSON.stringify(requestBody), + } + ); + + console.log("✅ 금지 식단 저장 성공:", response); + return response; + } catch (error: any) { + console.error("❌ 금지 식단 저장 실패:", error); + throw new Error(error.message || "금지 식단 저장에 실패했습니다."); + } + }, +}; From 26c3b981054764559cd2c92d1c4852446bdfa1f3 Mon Sep 17 00:00:00 2001 From: donggyu412 Date: Sun, 14 Dec 2025 13:18:35 +0900 Subject: [PATCH 2/3] =?UTF-8?q?7=EC=9D=BC=20=EC=9A=B4=EB=8F=99=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/RoutineRecommendNewScreen.tsx | 571 +++++++++++++++--- .../exercise/TempRoutineRecommendScreen.tsx | 2 +- src/services/recommendedExerciseAPI.ts | 35 +- src/utils/inbodyApi.ts | 30 +- 4 files changed, 544 insertions(+), 94 deletions(-) diff --git a/src/screens/exercise/RoutineRecommendNewScreen.tsx b/src/screens/exercise/RoutineRecommendNewScreen.tsx index b679ce3..e613a5d 100644 --- a/src/screens/exercise/RoutineRecommendNewScreen.tsx +++ b/src/screens/exercise/RoutineRecommendNewScreen.tsx @@ -18,7 +18,7 @@ import { Ionicons as Icon } from "@expo/vector-icons"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { recommendedExerciseAPI } from "../../services"; import { LinearGradient } from "expo-linear-gradient"; - +import { getLatestInBody } from "../../utils/inbodyApi"; const { width } = Dimensions.get("window"); const LOADING_MESSAGES = [ @@ -29,6 +29,46 @@ const LOADING_MESSAGES = [ "거의 다 됐어요! 득근할 준비 되셨나요?", ]; +//나이 계산 +const calculateAge = (birthDateString: string): number => { + if (!birthDateString) return 25; // 기본값 + const birthDate = new Date(birthDateString); + const today = new Date(); + let age = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return age; +}; + +// 성별 변환 (M/F -> male/female) +const mapGenderToAPI = (gender: string): string => { + return gender === "F" ? "female" : "male"; +}; + +// 운동 목표 변환 (한글/코드 -> API Enum) +const mapGoalToAPI = (goal: string): string => { + // 사용자의 healthGoal 값에 따라 매핑 (예시) + if (!goal) return "hypertrophy"; + if (goal.includes("다이어트") || goal === "DIET") return "weight_loss"; + if (goal.includes("근력") || goal.includes("비대") || goal === "MUSCLE") + return "hypertrophy"; + if (goal.includes("건강") || goal === "HEALTH") return "general_fitness"; + if (goal.includes("체력") || goal === "STAMINA") return "endurance"; + return "hypertrophy"; // 기본값 +}; + +// 경력 변환 (초급/중급/고급 -> beginner/intermediate/advanced) +const mapExperienceToAPI = (levelStr: string): string => { + const map: { [key: string]: string } = { + 초급: "beginner", + 중급: "intermediate", + 고급: "advanced", + }; + return map[levelStr] || "beginner"; +}; + const LoadingOverlay = ({ visible, messages = LOADING_MESSAGES, @@ -254,9 +294,11 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { duration: 0, calories: 0, }); - const [todayFocus, setTodayFocus] = useState(""); // ← focus 저장 - - // ✅ 오늘의 운동만 저장 (단일 배열) + const [todayFocus, setTodayFocus] = useState(""); + const [weeklyRoutine, setWeeklyRoutine] = useState([]); + const [isWeeklyMode, setIsWeeklyMode] = useState(false); + const [selectedDayIndex, setSelectedDayIndex] = useState(0); + // 오늘의 운동만 저장 (단일 배열) const [todayRoutine, setTodayRoutine] = useState([]); const fadeAnim = useRef(new Animated.Value(0)).current; @@ -339,7 +381,36 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { setEquipment([...equipment, item]); } }; + const selectWeeklyDay = ( + index: number, + fullRoutine: any[] = weeklyRoutine + ) => { + // 인자로 받은 루틴이 없으면 state에 저장된 weeklyRoutine 사용 + const targetRoutine = + fullRoutine && fullRoutine.length > 0 ? fullRoutine : weeklyRoutine; + + if (!targetRoutine || !targetRoutine[index]) return; + + const dayData = targetRoutine[index]; + setSelectedDayIndex(index); + + // UI 변환 로직 재사용 (기존 transformDailyExerciseToUI 활용) + const uiExercises = transformDailyExerciseToUI(dayData); + setTodayRoutine(uiExercises); + + // 메트릭스 업데이트 + if (dayData.metrics) { + setTodayMetrics({ + duration: Math.round(dayData.metrics.total_duration_min || 0), + calories: Math.round(dayData.metrics.total_kcal || 0), + }); + } + // Focus 업데이트 + if (dayData.focus) { + setTodayFocus(dayData.focus); + } + }; const handleCancelLoading = () => { Alert.alert("요청 취소", "운동 루틴 생성을 취소하시겠습니까?", [ { text: "계속 기다리기", style: "cancel" }, @@ -499,66 +570,329 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { } }; - const handleSaveRoutine = async () => { - // ✅ ExerciseScreen으로 이동하여 운동 시작 - const currentDate = new Date(); - const dateStr = `${currentDate.getFullYear()}-${String( - currentDate.getMonth() + 1 - ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; - - const groupKey = `ai_recommend_${Date.now()}`; - const groupTitle = todayFocus - ? `AI 추천 운동 - ${mapFocusToKorean(todayFocus)}` - : "AI 추천 운동"; - - // API 응답을 Activity 형식으로 변환 - const activities = todayRoutine.map((exercise: any, index: number) => { - // 세트 배열 생성 (API의 sets 개수만큼) - const setsCount = exercise.sets || 3; - const sets = Array.from({ length: setsCount }, (_, i) => ({ - id: i + 1, - order: i + 1, - weight: exercise.weight_kg || 0, - reps: exercise.reps || 0, - isCompleted: false, - })); - - return { - id: Date.now() + index, - name: exercise.name || "운동", - details: `${exercise.weight_kg || 0}kg ${ - exercise.reps || 0 - }회 ${setsCount}세트`, - time: currentDate.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - hour12: true, - }), - date: dateStr, - isCompleted: false, - externalId: exercise.exerciseId, - sets: sets, - saveTitle: groupTitle, - groupKey: groupKey, - // 추가 메타 정보 - target: exercise.target, - category: exercise.category, + const handleGetWeeklyRoutine = async () => { + setLoading(true); + setIsWeeklyMode(true); + + try { + console.log("📅 7일 운동 루틴 추천 시작"); + + // 1. 프로필 조회 + let userProfile; + try { + userProfile = await recommendedExerciseAPI.getProfile(); + await AsyncStorage.setItem("userInfo", JSON.stringify(userProfile)); + } catch (error: any) { + if (error.status === 401) { + Alert.alert("로그인 필요", "로그인이 만료되었습니다.", [ + { text: "확인", onPress: () => navigation.navigate("Login") }, + ]); + setLoading(false); + return; + } + const stored = await AsyncStorage.getItem("userInfo"); + if (stored) userProfile = JSON.parse(stored); + else throw new Error("사용자 정보를 불러올 수 없습니다."); + } + + // 2. 최신 인바디 데이터 조회 및 점수 변환 + let inbodyData = { + arms: { muscle_score: 0.0, fat_score: 0.0 }, + chest: { muscle_score: 0.0, fat_score: 0.0 }, + back: { muscle_score: 0.0, fat_score: 0.0 }, + shoulders: { muscle_score: 0.0, fat_score: 0.0 }, + legs: { muscle_score: 0.0, fat_score: 0.0 }, + glutes: { muscle_score: 0.0, fat_score: 0.0 }, + core: { muscle_score: 0.0, fat_score: 0.0 }, }; - }); - - console.log("🚀 ExerciseScreen으로 이동:", { - activitiesCount: activities.length, - groupTitle, - groupKey, - }); - - // ExerciseScreen으로 네비게이션 (탭 네비게이션) - navigation.navigate("Stats", { - screen: "Exercise", - params: { - recommendedExercises: activities, - }, - }); + + let currentWeight = userProfile.weight || 75; + + try { + console.log("📊 최신 인바디 데이터 조회 시도..."); + const response = await getLatestInBody(); + const latestInBody = response?.success ? response.inBody : response; + + if (latestInBody) { + // (1) 체중 업데이트 + if (latestInBody.muscleFatAnalysis?.weight) { + currentWeight = Number(latestInBody.muscleFatAnalysis.weight); + } else if (latestInBody.weight) { + currentWeight = Number(latestInBody.weight); + } + + // (2) 점수 계산 헬퍼 ((값-100)/100) + const calcScore = (val: any) => { + const num = Number(val); + if (isNaN(num)) return 0.0; + return Number(((num - 100) / 100).toFixed(2)); + }; + + const muscleObj = latestInBody.segmentalMuscleAnalysis || {}; + const fatObj = latestInBody.segmentalFatAnalysis || {}; + + // 데이터 추출 + const armM = + (Number(muscleObj.leftArm || 100) + + Number(muscleObj.rightArm || 100)) / + 2; + const armF = + (Number(fatObj.leftArm || 100) + Number(fatObj.rightArm || 100)) / + 2; + const legM = + (Number(muscleObj.leftLeg || 100) + + Number(muscleObj.rightLeg || 100)) / + 2; + const legF = + (Number(fatObj.leftLeg || 100) + Number(fatObj.rightLeg || 100)) / + 2; + const trunkM = Number(muscleObj.trunk || 100); + const trunkF = Number(fatObj.trunk || 100); + + const armScore = { + muscle_score: calcScore(armM), + fat_score: calcScore(armF), + }; + const legScore = { + muscle_score: calcScore(legM), + fat_score: calcScore(legF), + }; + const trunkScore = { + muscle_score: calcScore(trunkM), + fat_score: calcScore(trunkF), + }; + + inbodyData = { + arms: armScore, + shoulders: armScore, + chest: trunkScore, + back: trunkScore, + core: trunkScore, + legs: legScore, + glutes: legScore, + }; + } + } catch (e: any) { + console.log("ℹ️ 인바디 조회 실패 (기본값 사용):", e.message); + } + + // 3. 필수 정보 검증 + if (!userProfile?.id && !userProfile?.userId) { + throw new Error("유저 ID를 찾을 수 없습니다."); + } + + const finalUserId = userProfile.id + ? String(userProfile.id) + : userProfile.userId; + + // 4. 요청 바디 구성 + // [수정] 고급 선택 시 빈 값 방지를 위해 중급으로 변경하는 함수 + const getSafeExperienceLevel = (uiLevel: string) => { + if (uiLevel === "초급") return "beginner"; + if (uiLevel === "중급") return "intermediate"; + if (uiLevel === "고급") return "intermediate"; // 안전장치 + return "intermediate"; + }; + + const requestBody = { + user_id: finalUserId, + age: calculateAge(userProfile.birthDate), + sex: mapGenderToAPI(userProfile.gender), + goal: mapGoalToAPI(userProfile.healthGoal), + + // 🔥 화면 선택값을 반영하되, '고급'은 '중급'으로 안전하게 요청 + experience: getSafeExperienceLevel(level), + + environment: "gym", + + // 장비는 사용자 선택값 반영 (한글 그대로 전송) + available_equipment: equipment.length > 0 ? equipment : ["덤벨"], + + // 건강상태는 사용자 선택값 반영 + health_conditions: weakParts, + + plan_days: 7, + target_time_min: 60, + weight_kg: Number(currentWeight), + inbody: inbodyData, + }; + + console.log("📤 AI 요청 바디:", JSON.stringify(requestBody, null, 2)); + + // 5. API 호출 + const apiResponse = + await recommendedExerciseAPI.generateWeeklyExercisePlan(requestBody); + console.log("📦 AI 응답 수신 완료"); + + // 6. 응답 데이터 처리 + if (apiResponse && apiResponse.plan && Array.isArray(apiResponse.plan)) { + const sessionDetails = apiResponse.metrics?.session_details || []; + const mergedPlan = apiResponse.plan.map((dayPlan: any) => { + const dayMetrics = sessionDetails.find( + (d: any) => d.day === dayPlan.day + ); + return { + ...dayPlan, + metrics: { + total_duration_min: dayMetrics?.duration_min || 0, + total_kcal: dayMetrics?.kcal || 0, + }, + }; + }); + + setWeeklyRoutine(mergedPlan); + selectWeeklyDay(0, mergedPlan); + setShowRoutine(true); + Alert.alert("생성 완료", `AI가 루틴을 설계했습니다! 💪`); + } else { + throw new Error("루틴 데이터 형식이 올바르지 않습니다."); + } + } catch (error: any) { + console.error("❌ 7일 루틴 생성 에러:", error); + Alert.alert( + "실패", + "운동 루틴을 생성하지 못했습니다.\n" + (error.message || "서버 오류") + ); + setIsWeeklyMode(false); + } finally { + setLoading(false); + } + }; + + const handleSaveRoutine = async () => { + const today = new Date(); + let activitiesToSave: any[] = []; + + // 🅰️ [CASE 1] 7일 추천 모드일 때 + if (isWeeklyMode && weeklyRoutine.length > 0) { + console.log("📦 7일치 전체 루틴 저장을 시작합니다."); + + const groupKey = `ai_weekly_${Date.now()}`; // 7일치를 묶어줄 고유 키 + + // Day 1부터 Day 7까지 반복 + weeklyRoutine.forEach((dayPlan, dayIndex) => { + // 휴식일(Rest)이거나 운동이 없으면 건너뜀 + if (!dayPlan.exercises || dayPlan.exercises.length === 0) return; + + // 날짜 계산: 오늘 + dayIndex (0, 1, 2...) + const targetDate = new Date(today); + targetDate.setDate(today.getDate() + dayIndex); + + // YYYY-MM-DD 형식으로 변환 + const dateStr = `${targetDate.getFullYear()}-${String( + targetDate.getMonth() + 1 + ).padStart(2, "0")}-${String(targetDate.getDate()).padStart(2, "0")}`; + + const groupTitle = `AI 7일 루틴 (Day ${dayIndex + 1})`; + + // 해당 요일의 운동들을 리스트로 변환 + const dayActivities = dayPlan.exercises.map( + (exercise: any, exIndex: number) => { + // 세트 수 생성 + const setsCount = exercise.sets || 3; + const sets = Array.from({ length: setsCount }, (_, i) => ({ + id: i + 1, + order: i + 1, + weight: exercise.weight_kg || 0, + reps: exercise.reps || 0, + isCompleted: false, + })); + + // 상세 설명 텍스트 (예: "20kg 10회 3세트") + const detailParts = []; + if (exercise.weight_kg) detailParts.push(`${exercise.weight_kg}kg`); + if (exercise.reps) detailParts.push(`${exercise.reps}회`); + if (setsCount) detailParts.push(`${setsCount}세트`); + const details = detailParts.join(" ") || exercise.detail || ""; + + return { + // ID 충돌 방지를 위해 날짜 인덱스를 섞어서 생성 + id: Date.now() + dayIndex * 10000 + exIndex, + name: exercise.name || "운동", + details: details, + time: "00:00", + date: dateStr, + isCompleted: false, + externalId: exercise.exerciseId, + sets: sets, + saveTitle: groupTitle, + groupKey: groupKey, + target: exercise.target, + category: exercise.category, + }; + } + ); + + // 전체 리스트에 합치기 + activitiesToSave = [...activitiesToSave, ...dayActivities]; + }); + } + // [CASE 2] 1일 추천 + else { + if (todayRoutine.length === 0) { + Alert.alert("알림", "저장할 운동 데이터가 없습니다."); + return; + } + + const dateStr = `${today.getFullYear()}-${String( + today.getMonth() + 1 + ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + const groupKey = `ai_daily_${Date.now()}`; + const groupTitle = todayFocus + ? `AI 추천 - ${mapFocusToKorean(todayFocus)}` + : "AI 추천 운동"; + + activitiesToSave = todayRoutine.map((exercise: any, index: number) => { + const setsCount = exercise.sets || 3; + const sets = Array.from({ length: setsCount }, (_, i) => ({ + id: i + 1, + order: i + 1, + weight: exercise.weight_kg || 0, + reps: exercise.reps || 0, + isCompleted: false, + })); + + return { + id: Date.now() + index, + name: exercise.name || "운동", + details: exercise.detail, + time: today.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), + date: dateStr, + isCompleted: false, + externalId: exercise.exerciseId, + sets: sets, + saveTitle: groupTitle, + groupKey: groupKey, + target: exercise.target, + category: exercise.category, + }; + }); + } + + // 운동 목록이 있으면 다음 화면으로 이동 + if (activitiesToSave.length > 0) { + console.log( + `🚀 총 ${activitiesToSave.length}개의 운동 데이터를 Exercise 화면으로 전송합니다.` + ); + + // ExerciseScreen(운동 목록 화면)으로 이동하면서 데이터 전달 + navigation.navigate("Stats", { + screen: "Exercise", + params: { + recommendedExercises: activitiesToSave, + }, + }); + } else { + Alert.alert( + "알림", + "저장할 운동 루틴이 없습니다. (휴식일만 있거나 데이터 오류)" + ); + } }; const addRoutineToActivities = async (routine: any) => { @@ -731,6 +1065,29 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { + {/* 7일 추천 루틴 버튼 추가 */} + + + + + 7일 추천 루틴 받기 + + + + {/* 사용가능한 장비 선택 - 새로 추가 */} { > - + {/* 왼쪽 텍스트 영역 (flex: 1을 줘서 탭이 길어져도 아이콘과 안 겹치게 함) */} + AI 추천 운동 {todayFocus ? ` - ${mapFocusToKorean(todayFocus)}` : ""} + + {/* ▼▼▼ [위치 이동] Day 탭을 여기(날짜 바로 위)로 옮겼습니다 ▼▼▼ */} + {isWeeklyMode && weeklyRoutine.length > 0 && ( + + + {weeklyRoutine.map((_, index) => ( + selectWeeklyDay(index)} + activeOpacity={0.8} + > + + + Day {index + 1} + + + + ))} + + + )} + {/* ▲▲▲ Day 탭 끝 ▲▲▲ */} + - {new Date().toLocaleDateString("ko-KR", { - month: "long", - day: "numeric", - weekday: "short", - })} + {(() => { + // 날짜 표시 로직: 7일 모드면 선택된 날짜, 아니면 오늘 날짜 표시 + const displayDate = new Date(); + if (isWeeklyMode) { + displayDate.setDate( + displayDate.getDate() + selectedDayIndex + ); + } + return displayDate.toLocaleDateString("ko-KR", { + month: "long", + day: "numeric", + weekday: "short", + }); + })()} - - - - - diff --git a/src/screens/exercise/TempRoutineRecommendScreen.tsx b/src/screens/exercise/TempRoutineRecommendScreen.tsx index baf5f57..94b48c8 100644 --- a/src/screens/exercise/TempRoutineRecommendScreen.tsx +++ b/src/screens/exercise/TempRoutineRecommendScreen.tsx @@ -679,7 +679,7 @@ const TempRoutineRecommendScreen = ({ navigation }: any) => { style={{ marginRight: 8 }} /> - 무료 루틴 받기 + 1일 무료 루틴 받기 )} diff --git a/src/services/recommendedExerciseAPI.ts b/src/services/recommendedExerciseAPI.ts index 07f14f4..7dce081 100644 --- a/src/services/recommendedExerciseAPI.ts +++ b/src/services/recommendedExerciseAPI.ts @@ -1,5 +1,5 @@ -import { request } from "./apiConfig"; - +import { request, requestAI, ACCESS_TOKEN_KEY } from "./apiConfig"; +import AsyncStorage from "@react-native-async-storage/async-storage"; /** * 운동 루틴 추천 API */ @@ -86,7 +86,38 @@ export const recommendedExerciseAPI = { throw error; } }, + /** + * [NEW] 7일치 주간 운동 루틴 생성 (AI 서버) + */ + generateWeeklyExercisePlan: async (data: any): Promise => { + try { + console.log("📅 주간 루틴 생성 요청 (AI)"); + + // 1. 토큰 가져오기 + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + // 만약 ACCESS_TOKEN_KEY import가 안되면 문자열 "accessToken" 등을 직접 쓰셔도 됩니다. + // 예: const token = await AsyncStorage.getItem("accessToken"); + + console.log("🔑 토큰 존재 여부:", !!token); + + // 2. 헤더에 토큰 추가하여 요청 보내기 + const response = await requestAI("/ai/exercise_plan", { + // 찾으신 경로 사용 + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, // 🔥 핵심: 이 줄이 있어야 401이 해결됩니다. + }, + body: JSON.stringify(data), + }); + console.log("✅ 주간 루틴 생성 성공"); + return response; + } catch (error: any) { + console.error("❌ 주간 루틴 생성 실패:", error); + throw error; + } + }, /** * 2. 운동 플랜 저장 * @param planId - 저장할 플랜 ID diff --git a/src/utils/inbodyApi.ts b/src/utils/inbodyApi.ts index 9ad2c4a..5920b7e 100644 --- a/src/utils/inbodyApi.ts +++ b/src/utils/inbodyApi.ts @@ -144,7 +144,7 @@ export const getLatestInBody = async (): Promise => { 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] 데이터 없음:", { @@ -155,7 +155,7 @@ export const getLatestInBody = async (): Promise => { }); return null; } - + console.error("[INBODY][GET LATEST] API 에러:", { message: error.message, status, @@ -252,12 +252,12 @@ export const patchInBody = async ( 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, @@ -282,7 +282,7 @@ export const getInBodyByDatePath = async (date: string): Promise => { 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] 데이터 없음:", { @@ -293,7 +293,7 @@ export const getInBodyByDatePath = async (date: string): Promise => { }); return null; } - + console.error("[INBODY][GET BY DATE PATH] API 에러:", { message: error.message, status, @@ -426,16 +426,16 @@ export const uploadInBodyImage = async ( // 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, @@ -504,7 +504,7 @@ export const getInBodyComment = async ( ): Promise => { try { const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); - + // GET 요청을 위한 쿼리 파라미터 구성 const params = new URLSearchParams({ user_id: request.user_id, @@ -517,7 +517,7 @@ export const getInBodyComment = async ( 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 코멘트 조회 요청:", { @@ -562,7 +562,9 @@ export const getInBodyComment = async ( // 500 에러는 서버 내부 오류이므로 빈 코멘트 반환 (사용자에게는 표시 안 함) if (status === 500) { - console.warn("[INBODY][COMMENT] 서버 내부 오류 (500) - 코멘트 표시 안 함"); + console.warn( + "[INBODY][COMMENT] 서버 내부 오류 (500) - 코멘트 표시 안 함" + ); return { comment: "" }; } } else { @@ -632,4 +634,4 @@ export const getInBodyHistory = async (): Promise => { } return []; } -}; \ No newline at end of file +}; From 585332fcb91712c174f6dbca6a077cd7d62b240b Mon Sep 17 00:00:00 2001 From: donggyu412 Date: Sun, 14 Dec 2025 16:13:02 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EC=9A=B4=EB=8F=99=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5api=20=EC=9A=94=EC=95=BDapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exercise/RoutineRecommendNewScreen.tsx | 317 +++++++---- .../exercise/RoutineRecommendScreen.tsx | 538 ++++++++---------- .../exercise/TempRoutineRecommendScreen.tsx | 138 +++-- src/services/recommendedExerciseAPI.ts | 57 ++ 4 files changed, 589 insertions(+), 461 deletions(-) diff --git a/src/screens/exercise/RoutineRecommendNewScreen.tsx b/src/screens/exercise/RoutineRecommendNewScreen.tsx index e613a5d..ecaf56e 100644 --- a/src/screens/exercise/RoutineRecommendNewScreen.tsx +++ b/src/screens/exercise/RoutineRecommendNewScreen.tsx @@ -1,4 +1,3 @@ -// src/screens/RoutineRecommendNewScreen.tsx import React, { useState, useEffect, useRef } from "react"; import { View, @@ -758,37 +757,214 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { setLoading(false); } }; - const handleSaveRoutine = async () => { const today = new Date(); - let activitiesToSave: any[] = []; - // 🅰️ [CASE 1] 7일 추천 모드일 때 + // ------------------------------------------------------------ + // 🅰️ [CASE 1] 7일 추천 모드일 때 (서버 저장 + TEMP 요약 저장) + // ------------------------------------------------------------ if (isWeeklyMode && weeklyRoutine.length > 0) { - console.log("📦 7일치 전체 루틴 저장을 시작합니다."); + setLoading(true); - const groupKey = `ai_weekly_${Date.now()}`; // 7일치를 묶어줄 고유 키 + try { + // 1. [기존] 7일치 상세 루틴 저장 데이터 구성 + const serverPayload = weeklyRoutine + .map((dayPlan, dayIndex) => { + if (!dayPlan.exercises || dayPlan.exercises.length === 0) + return null; + + // 유효성 필터링 + const validExercises = dayPlan.exercises.filter((ex: any) => { + if (!ex.exerciseId) return false; + const id = String(ex.exerciseId); + return id.trim() !== "" && !id.startsWith("seed_"); + }); + + if (validExercises.length === 0) return null; + + const targetDate = new Date(today); + targetDate.setDate(today.getDate() + dayIndex); + + const dateStr = `${targetDate.getFullYear()}-${String( + targetDate.getMonth() + 1 + ).padStart(2, "0")}-${String(targetDate.getDate()).padStart( + 2, + "0" + )}`; + + const exercisesPayload = validExercises.map((ex: any) => ({ + exerciseId: String(ex.exerciseId), + name: ex.name, + target: ex.target || "", + })); - // Day 1부터 Day 7까지 반복 - weeklyRoutine.forEach((dayPlan, dayIndex) => { - // 휴식일(Rest)이거나 운동이 없으면 건너뜀 - if (!dayPlan.exercises || dayPlan.exercises.length === 0) return; + return { + date: dateStr, + exercises: exercisesPayload, + // TEMP 저장을 위해 원본 데이터도 잠시 들고 있음 (서버 전송시엔 제거됨) + _originalPlan: dayPlan, + }; + }) + .filter((day) => day !== null); - // 날짜 계산: 오늘 + dayIndex (0, 1, 2...) - const targetDate = new Date(today); - targetDate.setDate(today.getDate() + dayIndex); + if (serverPayload.length === 0) { + Alert.alert("알림", "저장할 수 있는 유효한 운동 데이터가 없습니다."); + setLoading(false); + return; + } - // YYYY-MM-DD 형식으로 변환 - const dateStr = `${targetDate.getFullYear()}-${String( - targetDate.getMonth() + 1 - ).padStart(2, "0")}-${String(targetDate.getDate()).padStart(2, "0")}`; + // 2. [NEW] TEMP 요약 API 호출 (운동이 있는 날짜별로 각각 호출) + console.log("📝 7일치 요약(TEMP) 저장을 시작합니다..."); + + // Promise.all로 병렬 처리하여 빠르게 저장 + const summaryPromises = serverPayload.map((dayData: any) => { + const original = dayData._originalPlan; + + // Focus 값 추출 (없으면 'Body' 기본값) + const focusValue = original.focus || "Body"; + + const summaryBody = { + date: dayData.date, // "2025-12-14" + focus: focusValue, // "Upper" + durationMin: original.metrics?.total_duration_min || 45, + kcal: original.metrics?.total_kcal || 300, + exerciseCount: dayData.exercises.length, + title: `${focusValue} day`, // "Upper day" + }; + + return recommendedExerciseAPI.saveTempSummary(summaryBody); + }); - const groupTitle = `AI 7일 루틴 (Day ${dayIndex + 1})`; + await Promise.all(summaryPromises); + console.log("✅ 7일치 요약(TEMP) 저장 완료!"); - // 해당 요일의 운동들을 리스트로 변환 - const dayActivities = dayPlan.exercises.map( - (exercise: any, exIndex: number) => { - // 세트 수 생성 + // 3. [기존] 7일치 상세 루틴 서버 저장 (cleanPayload로 _originalPlan 제거 후 전송) + const cleanPayload = serverPayload.map( + ({ _originalPlan, ...rest }) => rest + ); + + console.log("💾 상세 루틴 저장을 요청합니다..."); + const saveResponse = + await recommendedExerciseAPI.saveWeeklyExercisePlan({ + days: cleanPayload, + }); + + if (saveResponse && saveResponse.success) { + console.log("✅ 상세 루틴 저장 성공:", saveResponse.message); + + // 4. UI 데이터 변환 및 이동 (기존 로직 유지) + let activitiesToSave: any[] = []; + const groupKey = `ai_weekly_${Date.now()}`; + + weeklyRoutine.forEach((dayPlan, dayIndex) => { + if (!dayPlan.exercises || dayPlan.exercises.length === 0) return; + + const validExercises = dayPlan.exercises.filter((ex: any) => { + if (!ex.exerciseId) return false; + const id = String(ex.exerciseId); + return id.trim() !== "" && !id.startsWith("seed_"); + }); + + const targetDate = new Date(today); + targetDate.setDate(today.getDate() + dayIndex); + const dateStr = `${targetDate.getFullYear()}-${String( + targetDate.getMonth() + 1 + ).padStart(2, "0")}-${String(targetDate.getDate()).padStart( + 2, + "0" + )}`; + + const groupTitle = `AI 7일 루틴 (Day ${dayIndex + 1})`; + + const dayActivities = validExercises.map( + (exercise: any, exIndex: number) => { + const setsCount = exercise.sets || 3; + const sets = Array.from({ length: setsCount }, (_, i) => ({ + id: i + 1, + order: i + 1, + weight: exercise.weight_kg || 0, + reps: exercise.reps || 0, + isCompleted: false, + })); + const details = []; // (간략화) 상세 로직은 기존 코드 참조 + if (exercise.weight_kg) details.push(`${exercise.weight_kg}kg`); + if (exercise.reps) details.push(`${exercise.reps}회`); + if (setsCount) details.push(`${setsCount}세트`); + + return { + id: Date.now() + dayIndex * 10000 + exIndex, + name: exercise.name || "운동", + details: details.join(" "), + time: "00:00", + date: dateStr, + isCompleted: false, + externalId: exercise.exerciseId, + sets: sets, + saveTitle: groupTitle, + groupKey: groupKey, + target: exercise.target, + category: exercise.category, + }; + } + ); + activitiesToSave = [...activitiesToSave, ...dayActivities]; + }); + + navigation.navigate("Stats", { + screen: "Exercise", + params: { recommendedExercises: activitiesToSave, refresh: true }, + }); + } else { + throw new Error(saveResponse?.message || "저장에 실패했습니다."); + } + } catch (error: any) { + console.error("❌ 저장 실패:", error); + Alert.alert("오류", error.message || "저장 중 문제가 발생했습니다."); + } finally { + setLoading(false); + } + } + + // ------------------------------------------------------------ + // 🅱️ [CASE 2] 1일 추천 모드일 때 (TEMP 요약 저장 추가) + // ------------------------------------------------------------ + else { + if (todayRoutine.length === 0) { + Alert.alert("알림", "저장할 운동 데이터가 없습니다."); + return; + } + + setLoading(true); // 로딩 시작 + + try { + const dateStr = `${today.getFullYear()}-${String( + today.getMonth() + 1 + ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + // 1. [NEW] 1일치 TEMP 요약 저장 + const focusValue = todayFocus || "General"; + + const summaryBody = { + date: dateStr, + focus: focusValue, + durationMin: todayMetrics.duration || 45, + kcal: todayMetrics.calories || 300, + exerciseCount: todayRoutine.length, + title: `${focusValue} day`, // "Upper day" + }; + + console.log("📝 1일치 요약(TEMP) 저장을 요청합니다..."); + await recommendedExerciseAPI.saveTempSummary(summaryBody); + console.log("✅ 1일치 요약(TEMP) 저장 완료!"); + + // 2. [기존] UI 데이터 변환 및 이동 + const groupKey = `ai_daily_${Date.now()}`; + const groupTitle = todayFocus + ? `AI 추천 - ${mapFocusToKorean(todayFocus)}` + : "AI 추천 운동"; + + const activitiesToSave = todayRoutine.map( + (exercise: any, index: number) => { const setsCount = exercise.sets || 3; const sets = Array.from({ length: setsCount }, (_, i) => ({ id: i + 1, @@ -798,19 +974,15 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { isCompleted: false, })); - // 상세 설명 텍스트 (예: "20kg 10회 3세트") - const detailParts = []; - if (exercise.weight_kg) detailParts.push(`${exercise.weight_kg}kg`); - if (exercise.reps) detailParts.push(`${exercise.reps}회`); - if (setsCount) detailParts.push(`${setsCount}세트`); - const details = detailParts.join(" ") || exercise.detail || ""; - return { - // ID 충돌 방지를 위해 날짜 인덱스를 섞어서 생성 - id: Date.now() + dayIndex * 10000 + exIndex, + id: Date.now() + index, name: exercise.name || "운동", - details: details, - time: "00:00", + details: exercise.detail, + time: today.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), date: dateStr, isCompleted: false, externalId: exercise.exerciseId, @@ -823,75 +995,18 @@ const RoutineRecommendNewScreen = ({ navigation }: any) => { } ); - // 전체 리스트에 합치기 - activitiesToSave = [...activitiesToSave, ...dayActivities]; - }); - } - // [CASE 2] 1일 추천 - else { - if (todayRoutine.length === 0) { - Alert.alert("알림", "저장할 운동 데이터가 없습니다."); - return; + navigation.navigate("Stats", { + screen: "Exercise", + params: { + recommendedExercises: activitiesToSave, + }, + }); + } catch (error: any) { + console.error("❌ 1일 추천 저장 실패:", error); + Alert.alert("오류", "저장 중 문제가 발생했습니다."); + } finally { + setLoading(false); } - - const dateStr = `${today.getFullYear()}-${String( - today.getMonth() + 1 - ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; - - const groupKey = `ai_daily_${Date.now()}`; - const groupTitle = todayFocus - ? `AI 추천 - ${mapFocusToKorean(todayFocus)}` - : "AI 추천 운동"; - - activitiesToSave = todayRoutine.map((exercise: any, index: number) => { - const setsCount = exercise.sets || 3; - const sets = Array.from({ length: setsCount }, (_, i) => ({ - id: i + 1, - order: i + 1, - weight: exercise.weight_kg || 0, - reps: exercise.reps || 0, - isCompleted: false, - })); - - return { - id: Date.now() + index, - name: exercise.name || "운동", - details: exercise.detail, - time: today.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - hour12: true, - }), - date: dateStr, - isCompleted: false, - externalId: exercise.exerciseId, - sets: sets, - saveTitle: groupTitle, - groupKey: groupKey, - target: exercise.target, - category: exercise.category, - }; - }); - } - - // 운동 목록이 있으면 다음 화면으로 이동 - if (activitiesToSave.length > 0) { - console.log( - `🚀 총 ${activitiesToSave.length}개의 운동 데이터를 Exercise 화면으로 전송합니다.` - ); - - // ExerciseScreen(운동 목록 화면)으로 이동하면서 데이터 전달 - navigation.navigate("Stats", { - screen: "Exercise", - params: { - recommendedExercises: activitiesToSave, - }, - }); - } else { - Alert.alert( - "알림", - "저장할 운동 루틴이 없습니다. (휴식일만 있거나 데이터 오류)" - ); } }; diff --git a/src/screens/exercise/RoutineRecommendScreen.tsx b/src/screens/exercise/RoutineRecommendScreen.tsx index 86b61a3..05e787a 100644 --- a/src/screens/exercise/RoutineRecommendScreen.tsx +++ b/src/screens/exercise/RoutineRecommendScreen.tsx @@ -7,12 +7,15 @@ import { TouchableOpacity, Alert, ActivityIndicator, + RefreshControl, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useFocusEffect } from "@react-navigation/native"; import { Ionicons as Icon } from "@expo/vector-icons"; import { recommendedExerciseAPI } from "../../services/recommendedExerciseAPI"; import { authAPI } from "../../services"; + +// 운동 데이터 타입 정의 type Exercise = { exerciseId?: string; name: string; @@ -20,14 +23,16 @@ type Exercise = { sets?: number; reps?: number; weight?: number; + date?: string; }; +// 운동 플랜 타입 정의 type ExercisePlan = { planId: string; planName: string; createdAt: string; description?: string; - days: Exercise[][]; + days: Exercise[][]; // 날짜별 운동 배열 (Day 1, Day 2...) isServerPlan: boolean; }; @@ -36,59 +41,139 @@ const RoutineRecommendScreen = ({ navigation }: any) => { const [selectedPlan, setSelectedPlan] = useState(null); const [selectedDay, setSelectedDay] = useState(0); const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); const [selectedPlanIds, setSelectedPlanIds] = useState([]); + // 🔄 데이터 로드 함수 (서버 API 연동) + // 데이터 로드 함수 + // 🔄 데이터 로드 함수 (1일 + 7일 통합) const loadExercisePlans = async () => { try { setLoading(true); - // ✅ 추천받은 운동 조회 - const exercises = await recommendedExerciseAPI.getRecommendedExercises(); - - console.log("📥 추천받은 운동:", exercises); + console.log("🔄 운동 데이터 로드 시작..."); + + // 1. 두 API를 동시에 호출 (병렬 처리) + const [dailyResponse, weeklyResponse] = await Promise.all([ + // (1) 1일 추천 조회 (실패해도 빈 배열 반환하여 7일치는 보이게 함) + recommendedExerciseAPI.getRecommendedExercises().catch((err) => { + console.warn("⚠️ 1일 추천 조회 실패:", err); + return []; + }), + // (2) 7일 내역 조회 (실패해도 빈 객체 반환) + recommendedExerciseAPI.getRecommendedHistory().catch((err) => { + console.warn("⚠️ 7일 내역 조회 실패:", err); + return {}; + }), + ]); - if (!Array.isArray(exercises) || exercises.length === 0) { - setPlans([]); - return; + let combinedPlans: ExercisePlan[] = []; + + // ------------------------------------------------------------ + // 🅰️ [1일 추천 데이터 처리] (기존 로직 복구) + // ------------------------------------------------------------ + if (Array.isArray(dailyResponse) && dailyResponse.length > 0) { + console.log(`📥 1일 추천 데이터: ${dailyResponse.length}개`); + + // 타겟별로 그룹화 (예: 가슴, 등...) + const groupedByTarget = dailyResponse.reduce( + (acc: any, exercise: any) => { + const target = exercise.target || "전신"; + if (!acc[target]) acc[target] = []; + acc[target].push(exercise); + return acc; + }, + {} + ); + + // 플랜 객체로 변환 + const dailyPlans = Object.entries(groupedByTarget).map( + ([target, exs]: [string, any], index) => ({ + planId: `daily_${target}_${Date.now()}_${index}`, + planName: `오늘의 추천 - ${target}`, + createdAt: new Date().toISOString(), // 생성일 (오늘) + description: `${target} 집중 트레이닝 (1일)`, + days: [exs], // 1일치이므로 배열 안에 배열 하나 [[운동1, 운동2...]] + isServerPlan: true, + }) + ); + + combinedPlans = [...combinedPlans, ...dailyPlans]; } - // ✅ 임시: 타겟별로 그룹화 - const groupedByTarget = exercises.reduce((acc: any, exercise: any) => { - const target = exercise.target || "전신"; - if (!acc[target]) { - acc[target] = []; + // ------------------------------------------------------------ + // 🅱️ [7일 내역 데이터 처리] (객체 형태 { "2025-12-14": [...] }) + // ------------------------------------------------------------ + if ( + weeklyResponse && + typeof weeklyResponse === "object" && + !Array.isArray(weeklyResponse) && + Object.keys(weeklyResponse).length > 0 + ) { + console.log( + `📥 7일 내역 데이터: 날짜 ${ + Object.keys(weeklyResponse).length + }개 감지` + ); + + // 1. 날짜 오름차순 정렬 + const sortedDates = Object.keys(weeklyResponse).sort(); + + // 2. 날짜별 운동 리스트 추출 + const daysArray = sortedDates.map((date) => { + const exercises = weeklyResponse[date] || []; + return exercises.map((ex: any) => ({ + exerciseId: ex.exerciseId, + name: ex.name, + target: ex.target, + sets: ex.sets, + reps: ex.reps, + weight: ex.weight || ex.weight_kg, + date: date, + })); + }); + + // 3. 통합 플랜 생성 (데이터가 있는 경우만) + if (daysArray.length > 0) { + const weeklyPlan: ExercisePlan = { + planId: `weekly_history_${Date.now()}`, + planName: "나의 주간 운동 일정", + createdAt: sortedDates[0] || new Date().toISOString(), + description: `${sortedDates[0]}부터 시작되는 루틴 (${daysArray.length}일치)`, + days: daysArray, + isServerPlan: true, + }; + combinedPlans.push(weeklyPlan); } - acc[target].push(exercise); - return acc; - }, {}); - - // ✅ 각 타겟별로 플랜 생성 - const formattedPlans: ExercisePlan[] = Object.entries( - groupedByTarget - ).map(([target, exs]: [string, any], index) => ({ - planId: `plan_${target}_${Date.now()}_${index}`, - planName: `AI 추천 운동 - ${target}`, - createdAt: new Date().toISOString(), - description: `${target} 집중 운동`, - days: [exs], // 1일치 - isServerPlan: true, - })); - - console.log("✅ 변환된 플랜:", formattedPlans.length); - setPlans(formattedPlans); - } catch (error: any) { - console.error("Failed to load exercise plans", error); - Alert.alert( - "오류", - error.message || "운동 추천 내역을 불러오는데 실패했습니다." + } + + // ------------------------------------------------------------ + // 🏁 최종 합치기 및 정렬 + // ------------------------------------------------------------ + // 최신순 정렬 (생성일 기준 내림차순) + combinedPlans.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - setPlans([]); + + console.log(`✅ 최종 표시될 플랜: ${combinedPlans.length}개`); + setPlans(combinedPlans); + } catch (error: any) { + console.error("❌ loadExercisePlans 전체 에러:", error); + Alert.alert("오류", "데이터를 불러오는 중 문제가 발생했습니다."); } finally { setLoading(false); + setRefreshing(false); } }; + const onRefresh = React.useCallback(() => { + setRefreshing(true); + loadExercisePlans(); + }, []); + useFocusEffect( React.useCallback(() => { loadExercisePlans(); @@ -102,7 +187,6 @@ const RoutineRecommendScreen = ({ navigation }: any) => { togglePlanSelection(plan.planId); return; } - setSelectedPlan(plan); setSelectedDay(0); }; @@ -112,41 +196,6 @@ const RoutineRecommendScreen = ({ navigation }: any) => { setSelectedDay(0); }; - const handleDelete = async (plan: ExercisePlan) => { - Alert.alert( - "삭제", - `"${plan.planName}" 운동 플랜을 삭제하시겠습니까?\n(${plan.days.length}일치)`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - // TODO: 백엔드 삭제 API 추가되면 여기서 호출 - // await recommendedExerciseAPI.deleteExercisePlan(plan.planId); - - setPlans((prev) => prev.filter((p) => p.planId !== plan.planId)); - - if (selectedPlan?.planId === plan.planId) { - setSelectedPlan(null); - } - - Alert.alert("성공", "운동 플랜이 삭제되었습니다."); - } catch (error: any) { - console.error("삭제 실패:", error); - Alert.alert("오류", error.message || "삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ] - ); - }; - const togglePlanSelection = (planId: string) => { setSelectedPlanIds((prev) => prev.includes(planId) @@ -162,79 +211,29 @@ const RoutineRecommendScreen = ({ navigation }: any) => { setSelectedPlanIds(plans.map((p) => p.planId)); } }; + const handleNewRecommendPress = async () => { try { setLoading(true); - - // 사용자 프로필 조회 const profile = await authAPI.getProfile(); - - console.log("✅ 멤버십 타입:", profile.membershipType); - - // 멤버십 타입에 따라 다른 화면으로 이동 if (profile.membershipType === "FREE") { - console.log("➡️ 무료 회원 - TempRoutineRecommendScreen으로 이동"); navigation.navigate("TempRoutineRecommendScreen"); } else { - console.log("➡️ 프리미엄 회원 - RoutineRecommendNew로 이동"); navigation.navigate("RoutineRecommendNew"); } - } catch (error: any) { - console.error("❌ 프로필 조회 실패:", error); - Alert.alert("오류", "사용자 정보를 불러오는데 실패했습니다.", [ - { - text: "확인", - onPress: () => { - // 실패 시 기본적으로 무료 버전으로 이동 - navigation.navigate("TempRoutineRecommendScreen"); - }, - }, - ]); + } catch (error) { + navigation.navigate("TempRoutineRecommendScreen"); } finally { setLoading(false); } }; - const handleBulkDelete = async () => { - if (selectedPlanIds.length === 0) { - Alert.alert("알림", "삭제할 운동 플랜을 선택해주세요."); - return; - } - Alert.alert( - "일괄 삭제", - `선택한 ${selectedPlanIds.length}개의 운동 플랜을 삭제하시겠습니까?`, - [ - { text: "취소", style: "cancel" }, - { - text: "삭제", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - - // TODO: 백엔드 일괄 삭제 API 추가되면 여기서 호출 - - setPlans((prev) => - prev.filter((p) => !selectedPlanIds.includes(p.planId)) - ); - - setSelectedPlanIds([]); - setIsEditMode(false); - - Alert.alert( - "성공", - `${selectedPlanIds.length}개의 운동 플랜이 삭제되었습니다.` - ); - } catch (error: any) { - console.error("일괄 삭제 실패:", error); - Alert.alert("오류", error.message || "삭제에 실패했습니다."); - } finally { - setLoading(false); - } - }, - }, - ] - ); + const handleDelete = async (plan: ExercisePlan) => { + Alert.alert("알림", "서버 삭제 기능이 아직 연동되지 않았습니다."); + }; + + const handleBulkDelete = async () => { + Alert.alert("알림", "서버 삭제 기능이 아직 연동되지 않았습니다."); }; const currentDayExercises = selectedPlan?.days?.[selectedDay] || []; @@ -265,20 +264,29 @@ const RoutineRecommendScreen = ({ navigation }: any) => { {!plans.length && } - {loading && ( + {loading && !refreshing && ( 불러오는 중... )} - + + } + > {!selectedPlan ? ( plans.length === 0 ? ( - 추천받은 운동이 없습니다. - 운동 추천을 받아보세요! + 저장된 추천 내역이 없습니다. navigation.navigate("RoutineRecommendNew")} @@ -305,9 +313,7 @@ const RoutineRecommendScreen = ({ navigation }: any) => { size={24} color="#e3ff7c" /> - - 전체 선택 ({selectedPlanIds.length}/{plans.length}) - + 전체 선택 { - {loading ? ( - - ) : ( - - 새 운동 추천받기 - - )} + + + 새 운동 추천받기 + )} @@ -365,11 +366,10 @@ const RoutineRecommendScreen = ({ navigation }: any) => { /> )} - - {plan.isServerPlan ? "☁️" : "📱"} + {plan.days.length > 1 ? "🗓️" : "💪"} {plan.planName} @@ -390,40 +390,33 @@ const RoutineRecommendScreen = ({ navigation }: any) => { )} - - {plan.description && ( - - {plan.description} - - )} - + + {plan.description} + - - - 📅 {plan.days.length}일 운동 - - - - - 💪{" "} - {plan.days.reduce((sum, day) => sum + day.length, 0)} - 개 운동 + 1 + ? styles.exerciseCountBadge + : styles.badge, + ]} + > + 1 && + styles.exerciseCountBadgeText, + ]} + > + {plan.days.length > 1 + ? `📅 ${plan.days.length}일 루틴` + : "1일 루틴"} - {plan.isServerPlan && ( - - ☁️ 서버 - - )} - - {!isEditMode && ( - - 자세히 보기 → - - )} ))} @@ -433,107 +426,66 @@ const RoutineRecommendScreen = ({ navigation }: any) => { ← 목록으로 - - - - - {selectedPlan.planName} - - - {new Date(selectedPlan.createdAt).toLocaleDateString( - "ko-KR" - )} - - - {selectedPlan.isServerPlan && ( - - ☁️ 서버 - - )} - - - {selectedPlan.description && ( - - {selectedPlan.description} - - )} + {selectedPlan.planName} + + {selectedPlan.description} + - {/* ✅ days 길이만큼만 탭 표시 (1일~7일 동적) */} - {selectedPlan.days && selectedPlan.days.length > 0 && ( - <> - - {selectedPlan.days.map((_: any, index: number) => ( - 1 && ( + + {selectedPlan.days.map((_, index) => ( + setSelectedDay(index)} + > + setSelectedDay(index)} > - - {index + 1}일차 - - - ))} - - - - {currentDayExercises.length === 0 ? ( - - - {selectedDay + 1}일차에 운동이 없습니다 + {index + 1}일차 + + + ))} + + )} + + + {currentDayExercises.length === 0 ? ( + + 운동이 없습니다 + + ) : ( + currentDayExercises.map((exercise, index) => ( + + + 💪 + + + {exercise.name} + + {exercise.sets ? `${exercise.sets}세트 ` : ""} + {exercise.reps ? `${exercise.reps}회 ` : ""} + {exercise.weight ? `${exercise.weight}kg` : ""} - ) : ( - currentDayExercises.map( - (exercise: Exercise, index: number) => ( - - - 💪 - - - - {exercise.name} - - {exercise.target && ( - - - - {exercise.target} - - - - )} - {(exercise.sets || - exercise.reps || - exercise.weight) && ( - - {exercise.sets && `${exercise.sets}세트`} - {exercise.reps && ` × ${exercise.reps}회`} - {exercise.weight && ` × ${exercise.weight}kg`} - - )} - - - ) - ) - )} - - - )} + + )) + )} + )} @@ -590,12 +542,6 @@ const styles = StyleSheet.create({ marginTop: 20, marginBottom: 10, }, - emptySubtitle: { - fontSize: 14, - color: "#666666", - textAlign: "center", - marginBottom: 24, - }, goToRecommendBtn: { backgroundColor: "#e3ff7c", paddingVertical: 12, @@ -675,13 +621,11 @@ const styles = StyleSheet.create({ borderRadius: 12, backgroundColor: "#4a90e2", }, - exerciseCountBadge: { backgroundColor: "#e3ff7c" }, - serverBadge: { backgroundColor: "#8b5cf6" }, badgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, + exerciseCountBadge: { backgroundColor: "#e3ff7c" }, exerciseCountBadgeText: { fontSize: 12, fontWeight: "500", color: "#111111" }, + serverBadge: { backgroundColor: "#8b5cf6" }, serverBadgeText: { fontSize: 12, fontWeight: "500", color: "#ffffff" }, - cardFooter: { alignItems: "flex-end" }, - viewDetail: { fontSize: 14, color: "#e3ff7c", fontWeight: "500" }, detail: { gap: 20 }, backBtn: { backgroundColor: "#2a2a2a", @@ -692,19 +636,12 @@ const styles = StyleSheet.create({ }, backBtnText: { fontSize: 14, fontWeight: "500", color: "#ffffff" }, detailInfo: { backgroundColor: "#222222", padding: 16, borderRadius: 12 }, - detailHeader: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "flex-start", - marginBottom: 8, - }, detailPlanName: { fontSize: 20, fontWeight: "700", color: "#ffffff", marginBottom: 4, }, - detailDate: { fontSize: 14, fontWeight: "500", color: "#999999" }, detailDescription: { fontSize: 14, color: "#cccccc", @@ -748,31 +685,8 @@ const styles = StyleSheet.create({ }, exerciseIconText: { fontSize: 32 }, exerciseInfo: { flex: 1, gap: 6 }, - exerciseName: { - fontSize: 16, - fontWeight: "600", - color: "#ffffff", - }, - targetBadgeContainer: { - flexDirection: "row", - flexWrap: "wrap", - gap: 6, - }, - targetBadgeSmall: { - paddingVertical: 4, - paddingHorizontal: 10, - borderRadius: 12, - backgroundColor: "#4a90e2", - }, - targetBadgeSmallText: { - fontSize: 12, - fontWeight: "500", - color: "#ffffff", - }, - exerciseDetail: { - fontSize: 14, - color: "#aaaaaa", - }, + exerciseName: { fontSize: 16, fontWeight: "600", color: "#ffffff" }, + exerciseDetail: { fontSize: 14, color: "#aaaaaa" }, }); export default RoutineRecommendScreen; diff --git a/src/screens/exercise/TempRoutineRecommendScreen.tsx b/src/screens/exercise/TempRoutineRecommendScreen.tsx index 94b48c8..08cf506 100644 --- a/src/screens/exercise/TempRoutineRecommendScreen.tsx +++ b/src/screens/exercise/TempRoutineRecommendScreen.tsx @@ -312,7 +312,10 @@ const TempRoutineRecommendScreen = ({ navigation }: any) => { const [level, setLevel] = useState(""); const [targetParts, setTargetParts] = useState([]); const [equipment, setEquipment] = useState(["덤벨"]); - + const [todayMetrics, setTodayMetrics] = useState<{ + duration: number; + calories: number; + }>(); const [loading, setLoading] = useState(false); const [todayRoutine, setTodayRoutine] = useState([]); @@ -493,57 +496,96 @@ const TempRoutineRecommendScreen = ({ navigation }: any) => { }; const handleSaveRoutine = async () => { - const currentDate = new Date(); - const dateStr = `${currentDate.getFullYear()}-${String( - currentDate.getMonth() + 1 - ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; - - const groupKey = `ai_recommend_free_${Date.now()}`; - const groupTitle = todayFocus - ? `AI 추천 (무료) - ${mapFocusToKorean(todayFocus)}` - : "AI 추천 (무료)"; - - const activities = todayRoutine.map((exercise: any, index: number) => { - const setsCount = exercise.sets || 3; - const sets = Array.from({ length: setsCount }, (_, i) => ({ - id: i + 1, - order: i + 1, - weight: exercise.weight_kg || 0, - reps: exercise.reps || 0, - isCompleted: false, - })); - - return { - id: Date.now() + index, - name: exercise.name || "운동", - details: `${exercise.weight_kg || 0}kg ${ - exercise.reps || 0 - }회 ${setsCount}세트`, - time: currentDate.toLocaleTimeString("ko-KR", { - hour: "2-digit", - minute: "2-digit", - hour12: true, - }), + // 1. 저장할 운동 데이터가 있는지 확인 + if (todayRoutine.length === 0) { + Alert.alert("알림", "저장할 운동 데이터가 없습니다."); + return; + } + + // 로딩 상태가 있다면 true로 설정 (선택사항) + // setLoading(true); + + try { + const currentDate = new Date(); + const dateStr = `${currentDate.getFullYear()}-${String( + currentDate.getMonth() + 1 + ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`; + + // --------------------------------------------------------- + // ✅ [NEW] TEMP 요약 API 호출 (서버에 기록 남기기) + // --------------------------------------------------------- + const focusValue = todayFocus || "General"; + + const summaryBody = { date: dateStr, - isCompleted: false, - externalId: exercise.exerciseId, - targetMuscle: exercise.target, - sets: sets, - saveTitle: groupTitle, - groupKey: groupKey, + focus: focusValue, + // todayMetrics가 state에 있다면 사용하고, 없으면 기본값 사용 + durationMin: + typeof todayMetrics !== "undefined" ? todayMetrics.duration : 45, + kcal: typeof todayMetrics !== "undefined" ? todayMetrics.calories : 300, + exerciseCount: todayRoutine.length, + title: `${focusValue} day`, // 예: "Upper day" }; - }); - console.log("🚀 ExerciseScreen으로 이동:", { - activitiesCount: activities.length, - }); + console.log("📝 무료 회원 요약(TEMP) 저장 요청..."); + await recommendedExerciseAPI.saveTempSummary(summaryBody); + console.log("✅ 무료 회원 요약(TEMP) 저장 완료!"); + // --------------------------------------------------------- + + // 3. UI 데이터 변환 및 이동 (기존 로직 유지) + const groupKey = `ai_recommend_free_${Date.now()}`; + const groupTitle = todayFocus + ? `AI 추천 (무료) - ${mapFocusToKorean(todayFocus)}` + : "AI 추천 (무료)"; + + const activities = todayRoutine.map((exercise: any, index: number) => { + const setsCount = exercise.sets || 3; + const sets = Array.from({ length: setsCount }, (_, i) => ({ + id: i + 1, + order: i + 1, + weight: exercise.weight_kg || 0, + reps: exercise.reps || 0, + isCompleted: false, + })); + + return { + id: Date.now() + index, + name: exercise.name || "운동", + details: `${exercise.weight_kg || 0}kg ${ + exercise.reps || 0 + }회 ${setsCount}세트`, + time: currentDate.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), + date: dateStr, + isCompleted: false, + externalId: exercise.exerciseId, + targetMuscle: exercise.target, // 혹은 exercise.targetMuscle + sets: sets, + saveTitle: groupTitle, + groupKey: groupKey, + }; + }); - navigation.navigate("Stats", { - screen: "Exercise", - params: { - recommendedExercises: activities, - }, - }); + console.log("🚀 ExerciseScreen으로 이동:", { + activitiesCount: activities.length, + }); + + // 4. 페이지 이동 + navigation.navigate("Stats", { + screen: "Exercise", + params: { + recommendedExercises: activities, + }, + }); + } catch (error) { + console.error("❌ 저장 실패:", error); + Alert.alert("오류", "루틴 저장 중 문제가 발생했습니다."); + } finally { + // setLoading(false); + } }; const handleRecommendAgain = () => { diff --git a/src/services/recommendedExerciseAPI.ts b/src/services/recommendedExerciseAPI.ts index 7dce081..67b3ebb 100644 --- a/src/services/recommendedExerciseAPI.ts +++ b/src/services/recommendedExerciseAPI.ts @@ -256,4 +256,61 @@ export const recommendedExerciseAPI = { throw error; } }, + + getRecommendedHistory: async (): Promise => { + try { + console.log("📅 운동 추천 내역 조회 요청"); + // GET 요청 전송 + const response = await request( + "/api/exercise-recommendations/recommended-exercises", + { + method: "GET", + } + ); + return response; + } catch (error) { + console.error("❌ 내역 조회 실패:", error); + throw error; + } + }, + saveWeeklyExercisePlan: async (data: { days: any[] }): Promise => { + try { + console.log("💾 주간 운동 플랜 저장 요청"); + + // 백엔드가 알려준 저장 API 주소: /api/exercise-recommendations/weekly/save + const response = await request( + "/api/exercise-recommendations/weekly/save", + { + method: "POST", + body: JSON.stringify(data), + } + ); + + console.log("✅ 주간 운동 플랜 저장 성공"); + return response; + } catch (error: any) { + console.error("❌ 주간 운동 플랜 저장 실패:", error); + throw error; + } + }, + saveTempSummary: async (data: { + date: string; + focus: string; + durationMin: number; + kcal: number; + exerciseCount: number; + title: string; + }): Promise => { + try { + console.log("📝 추천 요약(TEMP) 저장 요청:", data.date, data.title); + const response = await request("/api/exercise-recommendations/temp", { + method: "POST", + body: JSON.stringify(data), + }); + return response; + } catch (error) { + console.error("❌ 추천 요약(TEMP) 저장 실패:", error); + throw error; // 에러를 상위로 던져서 저장 프로세스를 중단할지 결정 + } + }, };