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/screens/auth/KakaoOnboardingScreen.tsx b/src/screens/auth/KakaoOnboardingScreen.tsx index 51dfcce..0f15f32 100644 --- a/src/screens/auth/KakaoOnboardingScreen.tsx +++ b/src/screens/auth/KakaoOnboardingScreen.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, { useState } from "react"; import { View, Text, @@ -11,65 +11,70 @@ import { Alert, ActivityIndicator, Modal, -} from 'react-native'; -import {Picker} from '@react-native-picker/picker'; -import {authAPI} from '../../services'; +} 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'}, + { 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: '주 3-4회', value: '3-4일'}, - {label: '주 4회', value: '4일'}, - {label: '주 5회', value: '5일'}, - {label: '주 6회', value: '6일'}, - {label: '주 7회', value: '7일'}, + { label: "주 1회", value: "1일" }, + { label: "주 2회", value: "2일" }, + { label: "주 3회", value: "3일" }, + { label: "주 3-4회", value: "3-4일" }, + { 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'}, + { label: "초보자", value: "BEGINNER" }, + { label: "중급자", value: "INTERMEDIATE" }, + { label: "고급자", value: "ADVANCED" }, ]; -const KakaoOnboardingScreen = ({navigation}: any) => { +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: '', + 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 [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 [experienceLevelModalVisible, setExperienceLevelModalVisible] = + useState(false); const handleChange = (name: string, value: string) => { - setFormData(prev => ({...prev, [name]: value})); + setFormData((prev) => ({ ...prev, [name]: value })); if (errors[name]) { - setErrors((prev: any) => ({...prev, [name]: ''})); + setErrors((prev: any) => ({ ...prev, [name]: "" })); } }; @@ -83,53 +88,53 @@ const KakaoOnboardingScreen = ({navigation}: any) => { }; const generateMonthOptions = () => { - return Array.from({length: 12}, (_, i) => i + 1); + return Array.from({ length: 12 }, (_, i) => i + 1); }; const validateForm = () => { const newErrors: any = {}; if (!formData.birthYear || !formData.birthMonth || !formData.birthDay) { - newErrors.birth = '생년월일을 선택해주세요'; + newErrors.birth = "생년월일을 선택해주세요"; } if (!formData.gender) { - newErrors.gender = '성별을 선택해주세요'; + newErrors.gender = "성별을 선택해주세요"; } if (!formData.height.trim()) { - newErrors.height = '키를 입력해주세요'; + newErrors.height = "키를 입력해주세요"; } else { const heightNum = Number(formData.height); if (heightNum < 100 || heightNum > 250) { - newErrors.height = '키는 100cm 이상 250cm 이하여야 합니다'; - } + newErrors.height = "키는 100cm 이상 250cm 이하여야 합니다"; + } } if (!formData.weight.trim()) { - newErrors.weight = '체중을 입력해주세요'; + newErrors.weight = "체중을 입력해주세요"; } else { const weightNum = Number(formData.weight); if (weightNum < 30 || weightNum > 200) { - newErrors.weight = '체중은 30kg 이상 200kg 이하여야 합니다'; + newErrors.weight = "체중은 30kg 이상 200kg 이하여야 합니다"; } } if (!formData.weightGoal.trim()) { - newErrors.weightGoal = '목표 체중을 입력해주세요'; + newErrors.weightGoal = "목표 체중을 입력해주세요"; } else { const weightGoalNum = Number(formData.weightGoal); if (weightGoalNum < 30 || weightGoalNum > 200) { - newErrors.weightGoal = '목표 체중은 30kg 이상 200kg 이하여야 합니다'; - } + newErrors.weightGoal = "목표 체중은 30kg 이상 200kg 이하여야 합니다"; + } } if (!formData.healthGoal) { - newErrors.healthGoal = '헬스 목적을 선택해주세요'; + newErrors.healthGoal = "헬스 목적을 선택해주세요"; } if (!formData.workoutDaysPerWeek) { - newErrors.workoutDaysPerWeek = '주간 운동 횟수를 선택해주세요'; + newErrors.workoutDaysPerWeek = "주간 운동 횟수를 선택해주세요"; } setErrors(newErrors); @@ -143,13 +148,17 @@ const KakaoOnboardingScreen = ({navigation}: any) => { setLoading(true); try { - const birthDate = `${formData.birthYear}-${String(formData.birthMonth).padStart(2, '0')}-${String(formData.birthDay).padStart(2, '0')}`; - + const birthDate = `${formData.birthYear}-${String( + formData.birthMonth + ).padStart(2, "0")}-${String(formData.birthDay).padStart(2, "0")}`; + // 유연성향상, 체력증진, 자세교정은 "MAINTENANCE"로 변환 - const healthGoalValue = ['FLEXIBILITY', 'ENDURANCE', 'POSTURE'].includes(formData.healthGoal) - ? 'MAINTENANCE' + const healthGoalValue = ["FLEXIBILITY", "ENDURANCE", "POSTURE"].includes( + formData.healthGoal + ) + ? "MAINTENANCE" : formData.healthGoal; - + const onboardingData = { birthDate, agreePrivacy: true, @@ -165,44 +174,47 @@ const KakaoOnboardingScreen = ({navigation}: any) => { const response = await authAPI.submitOnboarding(onboardingData); setLoading(false); - + // 200 응답 (온보딩 완료) → Alert 없이 바로 홈으로 이동 // response.success가 false여도 200 응답이면 성공으로 처리 setTimeout(() => { - navigation.replace('Main'); + navigation.replace("Main"); }, 100); return; } catch (error: any) { - console.error('온보딩 제출 실패:', error); + console.error("온보딩 제출 실패:", error); setLoading(false); - + // 400 에러 (이미 온보딩 완료됨) → Alert 없이 홈으로 이동 if (error.status === 400) { setTimeout(() => { - navigation.replace('Main'); + navigation.replace("Main"); }, 100); return; } - + // 401 에러 (인증 필요) → 로그인 화면으로 이동 if (error.status === 401) { - navigation.replace('Login'); + navigation.replace("Login"); return; } - + // 기타 에러만 Alert 표시 - Alert.alert('오류', error.message || '신체정보 저장에 실패했습니다.'); + Alert.alert("오류", error.message || "신체정보 저장에 실패했습니다."); } }; return ( - + behavior={Platform.OS === "ios" ? "padding" : "height"} + > 신체정보를 입력해주세요 - 맞춤형 서비스를 제공하기 위해 필요합니다 + + 맞춤형 서비스를 제공하기 위해 필요합니다 + @@ -213,23 +225,30 @@ const KakaoOnboardingScreen = ({navigation}: any) => { style={styles.birthDateButtonContainer} onPress={() => { const currentYear = new Date().getFullYear(); - const defaultYear = formData.birthYear || String(currentYear - 20); - const defaultMonth = formData.birthMonth || '1'; - const defaultDay = formData.birthDay || '1'; - + 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); - }}> + }} + > { visible={pickerModalVisible} transparent={true} animationType="slide" - onRequestClose={() => setPickerModalVisible(false)}> + onRequestClose={() => setPickerModalVisible(false)} + > setPickerModalVisible(false)}> + onPress={() => setPickerModalVisible(false)} + > {}} - style={styles.modalContent}> + style={styles.modalContent} + > - setPickerModalVisible(false)}> + setPickerModalVisible(false)} + > 취소 생년월일 선택 { - handleChange('birthYear', tempPickerValue.year); - handleChange('birthMonth', tempPickerValue.month); - handleChange('birthDay', tempPickerValue.day); + handleChange("birthYear", tempPickerValue.year); + handleChange("birthMonth", tempPickerValue.month); + handleChange("birthDay", tempPickerValue.day); setPickerModalVisible(false); - }}> + }} + > 확인 @@ -276,8 +301,8 @@ const KakaoOnboardingScreen = ({navigation}: any) => { { - const newValue = {...tempPickerValue, year: value}; + onValueChange={(value) => { + const newValue = { ...tempPickerValue, year: value }; if (newValue.year && newValue.month) { const daysInMonth = new Date( Number(newValue.year), @@ -286,15 +311,20 @@ const KakaoOnboardingScreen = ({navigation}: any) => { ).getDate(); const currentDay = Number(newValue.day) || 1; if (currentDay > daysInMonth) { - newValue.day = '1'; + newValue.day = "1"; } } setTempPickerValue(newValue); }} style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> - {generateYearOptions().map(year => ( - + itemStyle={styles.pickerItemStyle} + > + {generateYearOptions().map((year) => ( + ))} @@ -302,8 +332,8 @@ const KakaoOnboardingScreen = ({navigation}: any) => { { - const newValue = {...tempPickerValue, month: value}; + onValueChange={(value) => { + const newValue = { ...tempPickerValue, month: value }; if (newValue.year && newValue.month) { const daysInMonth = new Date( Number(newValue.year), @@ -312,17 +342,18 @@ const KakaoOnboardingScreen = ({navigation}: any) => { ).getDate(); const currentDay = Number(newValue.day) || 1; if (currentDay > daysInMonth) { - newValue.day = '1'; + newValue.day = "1"; } } setTempPickerValue(newValue); }} style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> - {generateMonthOptions().map(month => ( + itemStyle={styles.pickerItemStyle} + > + {generateMonthOptions().map((month) => ( ))} @@ -332,32 +363,43 @@ const KakaoOnboardingScreen = ({navigation}: any) => { { - setTempPickerValue({...tempPickerValue, day: value}); + onValueChange={(value) => { + setTempPickerValue({ ...tempPickerValue, day: value }); }} style={styles.modalPicker} - itemStyle={styles.pickerItemStyle}> + 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 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'}); + setTempPickerValue({ + ...tempPickerValue, + day: "1", + }); }, 0); } - + return days; } - return Array.from({length: 31}, (_, i) => i + 1); - })().map(day => ( + return Array.from({ length: 31 }, (_, i) => i + 1); + })().map((day) => ( ))} @@ -373,10 +415,17 @@ const KakaoOnboardingScreen = ({navigation}: any) => { setGenderModalVisible(true)}> + onPress={() => setGenderModalVisible(true)} + > { visible={genderModalVisible} transparent={true} animationType="slide" - onRequestClose={() => setGenderModalVisible(false)}> + onRequestClose={() => setGenderModalVisible(false)} + > setGenderModalVisible(false)}> + onPress={() => setGenderModalVisible(false)} + > {}} - style={styles.modalContent}> + style={styles.modalContent} + > - setGenderModalVisible(false)}> + setGenderModalVisible(false)} + > 취소 성별 선택 - + - { - handleChange('gender', 'M'); + handleChange("gender", "M"); setGenderModalVisible(false); - }}> - + - 남성 - - - + 남성 + + + { - handleChange('gender', 'F'); + handleChange("gender", "F"); setGenderModalVisible(false); - }}> - + - 여성 - - - + formData.gender === "F" && + styles.genderOptionTextSelected, + ]} + > + 여성 + + + @@ -452,25 +512,25 @@ const KakaoOnboardingScreen = ({navigation}: any) => { {/* 키, 체중 */} - handleChange('height', text)} + value={formData.height} + onChangeText={(text) => handleChange("height", text)} keyboardType="number-pad" placeholderTextColor="rgba(255, 255, 255, 0.7)" /> {errors.height && ( {errors.height} )} - + - handleChange('weight', text)} + value={formData.weight} + onChangeText={(text) => handleChange("weight", text)} keyboardType="number-pad" placeholderTextColor="rgba(255, 255, 255, 0.7)" /> @@ -486,7 +546,7 @@ const KakaoOnboardingScreen = ({navigation}: any) => { style={styles.input} placeholder="목표 체중 (kg)" value={formData.weightGoal} - onChangeText={text => handleChange('weightGoal', text)} + onChangeText={(text) => handleChange("weightGoal", text)} keyboardType="number-pad" placeholderTextColor="rgba(255, 255, 255, 0.7)" /> @@ -500,13 +560,16 @@ const KakaoOnboardingScreen = ({navigation}: any) => { setHealthGoalModalVisible(true)}> - setHealthGoalModalVisible(true)} + > + opt.value === formData.healthGoal)?.label || '' - : '' + ? healthGoalOptions.find( + (opt) => opt.value === formData.healthGoal + )?.label || "" + : "" } placeholder="헬스 목적" placeholderTextColor="rgba(255, 255, 255, 0.7)" @@ -524,45 +587,54 @@ const KakaoOnboardingScreen = ({navigation}: any) => { visible={healthGoalModalVisible} transparent={true} animationType="slide" - onRequestClose={() => setHealthGoalModalVisible(false)}> + onRequestClose={() => setHealthGoalModalVisible(false)} + > setHealthGoalModalVisible(false)}> + onPress={() => setHealthGoalModalVisible(false)} + > {}} - style={styles.modalContent}> + style={styles.modalContent} + > - setHealthGoalModalVisible(false)}> + setHealthGoalModalVisible(false)} + > 취소 헬스 목적 선택 - + {healthGoalOptions.map((option) => ( - { - handleChange('healthGoal', option.value); + handleChange("healthGoal", option.value); setHealthGoalModalVisible(false); - }}> - + + formData.healthGoal === option.value && + styles.optionButtonTextSelected, + ]} + > {option.label} - - - ))} - + + + ))} + @@ -573,13 +645,16 @@ const KakaoOnboardingScreen = ({navigation}: any) => { setWorkoutDaysModalVisible(true)}> + onPress={() => setWorkoutDaysModalVisible(true)} + > opt.value === formData.workoutDaysPerWeek)?.label || '' - : '' + ? workoutDaysOptions.find( + (opt) => opt.value === formData.workoutDaysPerWeek + )?.label || "" + : "" } placeholder="주간 운동 횟수" placeholderTextColor="rgba(255, 255, 255, 0.7)" @@ -588,7 +663,9 @@ const KakaoOnboardingScreen = ({navigation}: any) => { /> {errors.workoutDaysPerWeek && ( - {errors.workoutDaysPerWeek} + + {errors.workoutDaysPerWeek} + )} @@ -597,45 +674,54 @@ const KakaoOnboardingScreen = ({navigation}: any) => { visible={workoutDaysModalVisible} transparent={true} animationType="slide" - onRequestClose={() => setWorkoutDaysModalVisible(false)}> + onRequestClose={() => setWorkoutDaysModalVisible(false)} + > setWorkoutDaysModalVisible(false)}> + onPress={() => setWorkoutDaysModalVisible(false)} + > {}} - style={styles.modalContent}> + style={styles.modalContent} + > - setWorkoutDaysModalVisible(false)}> + setWorkoutDaysModalVisible(false)} + > 취소 주간 운동 횟수 선택 - + {workoutDaysOptions.map((option) => ( - { - handleChange('workoutDaysPerWeek', option.value); + handleChange("workoutDaysPerWeek", option.value); setWorkoutDaysModalVisible(false); - }}> - + + formData.workoutDaysPerWeek === option.value && + styles.optionButtonTextSelected, + ]} + > {option.label} - - - ))} - + + + ))} + @@ -646,13 +732,16 @@ const KakaoOnboardingScreen = ({navigation}: any) => { setExperienceLevelModalVisible(true)}> + onPress={() => setExperienceLevelModalVisible(true)} + > opt.value === formData.experienceLevel)?.label || '' - : '' + ? experienceLevelOptions.find( + (opt) => opt.value === formData.experienceLevel + )?.label || "" + : "" } placeholder="운동 경험 수준 (선택)" placeholderTextColor="rgba(255, 255, 255, 0.7)" @@ -667,46 +756,55 @@ const KakaoOnboardingScreen = ({navigation}: any) => { visible={experienceLevelModalVisible} transparent={true} animationType="slide" - onRequestClose={() => setExperienceLevelModalVisible(false)}> + onRequestClose={() => setExperienceLevelModalVisible(false)} + > setExperienceLevelModalVisible(false)}> + onPress={() => setExperienceLevelModalVisible(false)} + > {}} - style={styles.modalContent}> + style={styles.modalContent} + > - setExperienceLevelModalVisible(false)}> + setExperienceLevelModalVisible(false)} + > 취소 운동 경험 수준 선택 - + {experienceLevelOptions.map((option) => ( - { - handleChange('experienceLevel', option.value); + handleChange("experienceLevel", option.value); setExperienceLevelModalVisible(false); - }}> - + + formData.experienceLevel === option.value && + styles.optionButtonTextSelected, + ]} + > {option.label} - - - ))} - - + + + ))} + + @@ -717,32 +815,33 @@ const KakaoOnboardingScreen = ({navigation}: any) => { style={styles.input} placeholder="헬스 고민 (선택)" value={formData.fitnessConcerns} - onChangeText={text => handleChange('fitnessConcerns', text)} + onChangeText={(text) => handleChange("fitnessConcerns", text)} placeholderTextColor="rgba(255, 255, 255, 0.7)" multiline /> - + - + onPress={handleSubmit} + disabled={loading} + > {loading ? ( ) : ( 완료 )} - - - + + + ); }; const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#252525', + backgroundColor: "#252525", }, scrollContent: { flexGrow: 1, @@ -755,13 +854,13 @@ const styles = StyleSheet.create({ }, title: { fontSize: 24, - fontWeight: '700', - color: '#ffffff', + fontWeight: "700", + color: "#ffffff", marginBottom: 8, }, subtitle: { fontSize: 14, - color: 'rgba(255, 255, 255, 0.7)', + color: "rgba(255, 255, 255, 0.7)", }, form: { marginBottom: 30, @@ -770,102 +869,102 @@ const styles = StyleSheet.create({ marginBottom: 20, }, inputRow: { - flexDirection: 'row', + flexDirection: "row", gap: 12, }, inputHalf: { flex: 1, }, input: { - width: '100%', + width: "100%", height: 60, - backgroundColor: '#434343', + backgroundColor: "#434343", borderWidth: 0, borderRadius: 20, paddingHorizontal: 20, fontSize: 16, - fontWeight: '400', - color: '#ffffff', + fontWeight: "400", + color: "#ffffff", }, birthDateButtonContainer: { - width: '100%', + width: "100%", }, errorMessage: { - color: '#ff6b6b', + color: "#ff6b6b", fontSize: 14, marginTop: 5, marginLeft: 5, }, submitBtn: { - width: '100%', + width: "100%", height: 60, - backgroundColor: '#ffffff', + backgroundColor: "#ffffff", borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, submitBtnDisabled: { opacity: 0.6, }, submitBtnText: { - color: '#000000', + color: "#000000", fontSize: 18, - fontWeight: '700', + fontWeight: "700", }, modalOverlay: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'flex-end', + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", }, modalContent: { - backgroundColor: '#252525', + backgroundColor: "#252525", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingBottom: 40, }, modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", paddingHorizontal: 20, paddingVertical: 16, borderBottomWidth: 1, - borderBottomColor: '#434343', + borderBottomColor: "#434343", }, modalTitle: { fontSize: 18, - fontWeight: '700', - color: '#ffffff', + fontWeight: "700", + color: "#ffffff", }, modalCancelText: { fontSize: 16, - color: 'rgba(255, 255, 255, 0.7)', + color: "rgba(255, 255, 255, 0.7)", }, modalConfirmText: { fontSize: 16, - color: '#ffffff', - fontWeight: '600', + color: "#ffffff", + fontWeight: "600", }, birthPickerGroup: { - flexDirection: 'row', + flexDirection: "row", paddingHorizontal: 20, paddingVertical: 20, }, birthPickerItem: { flex: 1, - alignItems: 'center', + alignItems: "center", }, birthPickerLabelTop: { fontSize: 14, - color: 'rgba(255, 255, 255, 0.7)', + color: "rgba(255, 255, 255, 0.7)", marginBottom: 8, }, modalPicker: { - width: '100%', + width: "100%", height: 150, }, pickerItemStyle: { - color: '#ffffff', + color: "#ffffff", }, genderOptionContainer: { paddingHorizontal: 20, @@ -874,52 +973,51 @@ const styles = StyleSheet.create({ genderOption: { paddingVertical: 16, paddingHorizontal: 20, - backgroundColor: '#434343', + backgroundColor: "#434343", borderRadius: 12, marginBottom: 12, - alignItems: 'center', + alignItems: "center", }, genderOptionSelected: { - backgroundColor: '#ffffff', + backgroundColor: "#ffffff", }, genderOptionText: { fontSize: 16, - color: '#ffffff', + color: "#ffffff", }, genderOptionTextSelected: { - color: '#000000', - fontWeight: '600', + color: "#000000", + fontWeight: "600", }, modalOptionContainer: { paddingHorizontal: 20, paddingVertical: 20, }, optionGrid: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", gap: 12, }, optionButton: { flex: 1, - minWidth: '45%', + minWidth: "45%", paddingVertical: 16, paddingHorizontal: 20, - backgroundColor: '#434343', + backgroundColor: "#434343", borderRadius: 12, - alignItems: 'center', + alignItems: "center", }, optionButtonSelected: { - backgroundColor: '#ffffff', + backgroundColor: "#ffffff", }, optionButtonText: { fontSize: 16, - color: '#ffffff', + color: "#ffffff", }, optionButtonTextSelected: { - color: '#000000', - fontWeight: '600', + color: "#000000", + fontWeight: "600", }, }); export default KakaoOnboardingScreen; - diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index a60b493..ef9235c 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, { useState, useEffect } from "react"; import { View, Text, @@ -10,53 +10,58 @@ import { 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'; +} 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'); + KakaoLogin = require("@react-native-seoul/kakao-login"); } catch (e) { - console.log('카카오 로그인 네이티브 모듈을 사용할 수 없습니다 (Expo Go 환경일 수 있음)'); + console.log( + "카카오 로그인 네이티브 모듈을 사용할 수 없습니다 (Expo Go 환경일 수 있음)" + ); } -const LoginScreen = ({navigation}: any) => { +const LoginScreen = ({ navigation }: any) => { const [formData, setFormData] = useState({ - username: '', - password: '', + username: "", + password: "", }); - const [errors, setErrors] = useState<{username?: string; password?: string}>({}); + const [errors, setErrors] = useState<{ + username?: string; + password?: string; + }>({}); const [loading, setLoading] = useState(false); const handleChange = (name: string, value: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, [name]: value, })); if (errors[name as keyof typeof errors]) { - setErrors(prev => ({ + setErrors((prev) => ({ ...prev, - [name]: '', + [name]: "", })); } }; const validateForm = () => { - const newErrors: {username?: string; password?: string} = {}; + const newErrors: { username?: string; password?: string } = {}; if (!formData.username.trim()) { - newErrors.username = '아이디를 입력해주세요'; + newErrors.username = "아이디를 입력해주세요"; } if (!formData.password.trim()) { - newErrors.password = '비밀번호를 입력해주세요'; + newErrors.password = "비밀번호를 입력해주세요"; } setErrors(newErrors); @@ -70,24 +75,27 @@ const LoginScreen = ({navigation}: any) => { setLoading(true); try { - const response = await authAPI.login(formData.username, formData.password); - + const response = await authAPI.login( + formData.username, + formData.password + ); + if (response.success && response.accessToken) { - navigation.replace('Main'); + navigation.replace("Main"); } else { - const errorMessage = response.message || '로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { + const errorMessage = response.message || "로그인에 실패했습니다"; + if (typeof window !== "undefined" && (window as any).alert) { (window as any).alert(`로그인 실패\n${errorMessage}`); } else { - Alert.alert('로그인 실패', errorMessage); + Alert.alert("로그인 실패", errorMessage); } } } catch (error: any) { - const errorMessage = error.message || '로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { + const errorMessage = error.message || "로그인에 실패했습니다"; + if (typeof window !== "undefined" && (window as any).alert) { (window as any).alert(`로그인 실패\n${errorMessage}`); } else { - Alert.alert('로그인 실패', errorMessage); + Alert.alert("로그인 실패", errorMessage); } } finally { setLoading(false); @@ -96,54 +104,62 @@ const LoginScreen = ({navigation}: any) => { useEffect(() => { // 🔹 ② redirect_uri 가로채기 (가장 중요) - const subscription = Linking.addEventListener('url', async ({ url }) => { - console.log('🔗 [카카오 로그인] 딥링크 수신:', url); - + 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; + const refreshToken = parsed.queryParams?.refreshToken as + | string + | undefined; // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 if (accessToken) { - console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); + 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); + await AsyncStorage.setItem("userId", userId); } - + // membershipType 저장 (기본값 FREE) - const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; - await AsyncStorage.setItem('membershipType', membershipType); + const membershipType = + (parsed.queryParams?.membershipType as string | undefined) || + "FREE"; + await AsyncStorage.setItem("membershipType", membershipType); // 온보딩 여부 확인 (isOnboarded 우선 확인) const isOnboarded = parsed.queryParams?.isOnboarded; const onboarded = parsed.queryParams?.onboarded; - const shouldOnboard = isOnboarded === 'false' || onboarded === 'false'; - + const shouldOnboard = + isOnboarded === "false" || onboarded === "false"; + // 신규 유저 확인 (온보딩이 완료된 경우에만) const newUser = parsed.queryParams?.newUser; - const isNewUser = newUser === 'true'; - - console.log('✅ [카카오 로그인] 토큰 저장 완료'); + const isNewUser = newUser === "true"; + + console.log("✅ [카카오 로그인] 토큰 저장 완료"); if (shouldOnboard || isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } } catch (error: any) { - console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); - Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); + console.error("❌ [카카오 로그인] 토큰 저장 실패:", error); + Alert.alert( + "로그인 실패", + error.message || "토큰 저장 중 오류가 발생했습니다." + ); } finally { setLoading(false); } @@ -152,30 +168,30 @@ const LoginScreen = ({navigation}: any) => { // 인증 코드가 있는 경우 (백엔드 API 호출 필요) if (!code) { - console.log('⚠️ [카카오 로그인] 딥링크에 code 또는 accessToken이 없음'); + console.log("⚠️ [카카오 로그인] 딥링크에 code 또는 accessToken이 없음"); return; } - console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); + console.log("🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작"); try { setLoading(true); - + // 👉 여기서 서버 API 호출 const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', + "https://www.intelfits.com/api/auth/kakao/login", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), } ); - console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); + console.log("🔵 [카카오 로그인] 백엔드 응답 상태:", res.status); const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); + console.log("🔵 [카카오 로그인] 백엔드 응답 데이터:", data); if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); + throw new Error(data.message || "카카오 로그인에 실패했습니다"); } // 토큰 저장 @@ -186,34 +202,35 @@ const LoginScreen = ({navigation}: any) => { await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); } if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); + await AsyncStorage.setItem("userId", String(data.userId)); } if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); + await AsyncStorage.setItem("membershipType", data.membershipType); } else { - await AsyncStorage.setItem('membershipType', 'FREE'); + await AsyncStorage.setItem("membershipType", "FREE"); } // 온보딩 여부 확인 (isOnboarded 우선 확인) // isOnboarded === false: 온보딩 미완료 → 카카오 온보딩 - const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; + const isOnboarded = + data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; const shouldOnboard = isOnboarded === false; - + // 신규 유저 확인 (온보딩이 완료된 경우에만) const isNewUser = data.newUser === true; - + if (shouldOnboard || isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); - const errorMessage = error.message || '카카오 로그인에 실패했습니다'; - if (Platform.OS === 'web') { + console.error("카카오 로그인 처리 실패:", error); + const errorMessage = error.message || "카카오 로그인에 실패했습니다"; + if (Platform.OS === "web") { window.alert(`로그인 실패\n${errorMessage}`); } else { - Alert.alert('로그인 실패', errorMessage); + Alert.alert("로그인 실패", errorMessage); } } finally { setLoading(false); @@ -229,12 +246,12 @@ const LoginScreen = ({navigation}: any) => { // 초기 URL에 코드가 있으면 처리 try { setLoading(true); - + const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', + "https://www.intelfits.com/api/auth/kakao/login", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), } ); @@ -242,7 +259,7 @@ const LoginScreen = ({navigation}: any) => { const data = await res.json(); if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); + throw new Error(data.message || "카카오 로그인에 실패했습니다"); } // 토큰 저장 @@ -253,25 +270,28 @@ const LoginScreen = ({navigation}: any) => { await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); } if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); + await AsyncStorage.setItem("membershipType", data.membershipType); } else { - await AsyncStorage.setItem('membershipType', 'FREE'); + await AsyncStorage.setItem("membershipType", "FREE"); } // 온보딩 여부 확인 (isOnboarded 우선 확인) - const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; + const isOnboarded = + data.isOnboarded !== undefined + ? data.isOnboarded + : data.onboarded; const shouldOnboard = isOnboarded === false; - + // 신규 유저 확인 (온보딩이 완료된 경우에만) const isNewUser = data.newUser === true; - + if (shouldOnboard || isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); + console.error("카카오 로그인 처리 실패:", error); } finally { setLoading(false); } @@ -287,58 +307,67 @@ const LoginScreen = ({navigation}: any) => { const handleKakaoLogin = async () => { try { setLoading(true); - console.log('🔵 [카카오 로그인] 시작'); + 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`; + 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'); + window.open(loginUrl, "_blank"); setLoading(false); return; } // ✅ 방법 1: @react-native-seoul/kakao-login 사용 (네이티브 빌드에서만 작동) - if (KakaoLogin && typeof KakaoLogin.login === 'function') { + if (KakaoLogin && typeof KakaoLogin.login === "function") { try { - console.log('🔵 [카카오 로그인] 네이티브 모듈 사용'); + console.log("🔵 [카카오 로그인] 네이티브 모듈 사용"); const token = await KakaoLogin.login(); - console.log('✅ [카카오 로그인] 토큰 받음:', token); + console.log("✅ [카카오 로그인] 토큰 받음:", token); // 카카오 액세스 토큰을 백엔드로 전송하여 JWT 토큰 발급받기 // 또는 카카오 프로필 정보를 가져와서 백엔드에 전송 const profile = await KakaoLogin.getProfile(); - console.log('✅ [카카오 로그인] 프로필:', profile); + console.log("✅ [카카오 로그인] 프로필:", profile); // 백엔드 API 호출 (카카오 ID로 로그인/회원가입) - console.log('🔵 [카카오 로그인] 백엔드 API 호출 시작'); + console.log("🔵 [카카오 로그인] 백엔드 API 호출 시작"); const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', + "https://www.intelfits.com/api/auth/kakao/login", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kakaoAccessToken: token.accessToken, kakaoId: profile.id, nickname: profile.nickname, - profileImage: profile.profileImageUrl + profileImage: profile.profileImageUrl, }), } ); - console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); + console.log("🔵 [카카오 로그인] 백엔드 응답 상태:", res.status); const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); + console.log("🔵 [카카오 로그인] 백엔드 응답 데이터:", data); if (!res.ok) { - console.error('❌ [카카오 로그인] 백엔드 응답 실패:', { + console.error("❌ [카카오 로그인] 백엔드 응답 실패:", { status: res.status, statusText: res.statusText, - data: data + data: data, }); - throw new Error(data.message || `카카오 로그인에 실패했습니다 (${res.status})`); + throw new Error( + data.message || `카카오 로그인에 실패했습니다 (${res.status})` + ); } // 토큰 저장 @@ -349,58 +378,64 @@ const LoginScreen = ({navigation}: any) => { await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); } if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); + await AsyncStorage.setItem("userId", String(data.userId)); } if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); + await AsyncStorage.setItem("membershipType", data.membershipType); } else { - await AsyncStorage.setItem('membershipType', 'FREE'); + await AsyncStorage.setItem("membershipType", "FREE"); } // 온보딩 여부 확인 (isOnboarded 우선 확인) - const isOnboarded = data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; + const isOnboarded = + data.isOnboarded !== undefined ? data.isOnboarded : data.onboarded; const shouldOnboard = isOnboarded === false; - + // 신규 유저 확인 (온보딩이 완료된 경우에만) const isNewUser = data.newUser === true; - + if (shouldOnboard || isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } return; } catch (nativeError: any) { - console.error('❌ [카카오 로그인] 네이티브 모듈 실패:', nativeError); - console.log('⚠️ [카카오 로그인] 네이티브 모듈 실패, WebBrowser 방식으로 전환:', nativeError.message); + console.error("❌ [카카오 로그인] 네이티브 모듈 실패:", nativeError); + console.log( + "⚠️ [카카오 로그인] 네이티브 모듈 실패, WebBrowser 방식으로 전환:", + nativeError.message + ); // 네이티브 모듈 실패 시 WebBrowser 방식으로 폴백 setLoading(false); // 로딩 상태 해제 } } // ✅ 방법 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`; + 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); - console.log('🔵 [카카오 로그인] WebBrowser 방식 사용'); - console.log('🔵 [카카오 로그인] URL:', loginUrl); - // 딥링크 스킴 설정 (앱 내부 브라우저에서 열리도록) - const deepLinkScheme = 'intelfit://auth/kakao'; - console.log('🔵 [카카오 로그인] 딥링크 스킴:', deepLinkScheme); - + const deepLinkScheme = "intelfit://auth/kakao"; + console.log("🔵 [카카오 로그인] 딥링크 스킴:", deepLinkScheme); + let result; try { // openAuthSessionAsync는 앱 내부 브라우저를 엽니다 result = await WebBrowser.openAuthSessionAsync( - loginUrl, + loginUrl, deepLinkScheme ); } catch (browserError: any) { - console.error('❌ [카카오 로그인] WebBrowser 에러:', browserError); + console.error("❌ [카카오 로그인] WebBrowser 에러:", browserError); // WebBrowser 실패 시 openBrowserAsync로 폴백 (앱 내부 브라우저) - console.log('🔄 [카카오 로그인] openBrowserAsync로 폴백'); + console.log("🔄 [카카오 로그인] openBrowserAsync로 폴백"); try { await WebBrowser.openBrowserAsync(loginUrl); // openBrowserAsync는 딥링크를 자동으로 처리하지 않으므로 @@ -408,66 +443,79 @@ const LoginScreen = ({navigation}: any) => { setLoading(false); return; } catch (fallbackError: any) { - console.error('❌ [카카오 로그인] openBrowserAsync도 실패:', fallbackError); - Alert.alert('오류', '카카오 로그인 페이지를 열 수 없습니다.'); + console.error( + "❌ [카카오 로그인] openBrowserAsync도 실패:", + fallbackError + ); + Alert.alert("오류", "카카오 로그인 페이지를 열 수 없습니다."); setLoading(false); return; } } - - console.log('🔵 [카카오 로그인] 결과:', result); + + console.log("🔵 [카카오 로그인] 결과:", result); // openAuthSessionAsync가 성공적으로 딥링크를 받은 경우 - if (result.type === 'success' && result.url) { - console.log('🔵 [카카오 로그인] 딥링크 URL 받음:', result.url); - + 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; + const accessToken = parsed.queryParams?.accessToken as + | string + | undefined; + const refreshToken = parsed.queryParams?.refreshToken as + | string + | undefined; // 백엔드가 이미 처리해서 토큰을 딥링크에 포함한 경우 if (accessToken) { - console.log('✅ [카카오 로그인] 토큰이 딥링크에 포함됨'); + 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); + await AsyncStorage.setItem("userId", userId); } - + // membershipType 저장 (기본값 FREE) - const membershipType = (parsed.queryParams?.membershipType as string | undefined) || 'FREE'; - await AsyncStorage.setItem('membershipType', membershipType); + const membershipType = + (parsed.queryParams?.membershipType as string | undefined) || + "FREE"; + await AsyncStorage.setItem("membershipType", membershipType); // 온보딩 여부 확인 (isOnboarded 우선 확인) const isOnboarded = parsed.queryParams?.isOnboarded; const onboarded = parsed.queryParams?.onboarded; - const shouldOnboard = isOnboarded === 'false' || onboarded === 'false'; - + const shouldOnboard = + isOnboarded === "false" || onboarded === "false"; + // 신규 유저 확인 (온보딩이 완료된 경우에만) const newUser = parsed.queryParams?.newUser; - const isNewUser = newUser === 'true'; + const isNewUser = newUser === "true"; - console.log('✅ [카카오 로그인] 토큰 저장 완료'); + console.log("✅ [카카오 로그인] 토큰 저장 완료"); if (shouldOnboard || isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } } catch (error: any) { - console.error('❌ [카카오 로그인] 토큰 저장 실패:', error); - Alert.alert('로그인 실패', error.message || '토큰 저장 중 오류가 발생했습니다.'); + console.error("❌ [카카오 로그인] 토큰 저장 실패:", error); + Alert.alert( + "로그인 실패", + error.message || "토큰 저장 중 오류가 발생했습니다." + ); } finally { setLoading(false); } @@ -476,26 +524,28 @@ const LoginScreen = ({navigation}: any) => { // 인증 코드가 있는 경우 (백엔드 API 호출 필요) if (code) { - console.log('🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작'); + console.log( + "🔵 [카카오 로그인] 인증 코드 받음, 백엔드 API 호출 시작" + ); try { setLoading(true); - + // 👉 서버 API 호출 const res = await fetch( - 'https://www.intelfits.com/api/auth/kakao/login', + "https://www.intelfits.com/api/auth/kakao/login", { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), } ); - console.log('🔵 [카카오 로그인] 백엔드 응답 상태:', res.status); + console.log("🔵 [카카오 로그인] 백엔드 응답 상태:", res.status); const data = await res.json(); - console.log('🔵 [카카오 로그인] 백엔드 응답 데이터:', data); + console.log("🔵 [카카오 로그인] 백엔드 응답 데이터:", data); if (!res.ok) { - throw new Error(data.message || '카카오 로그인에 실패했습니다'); + throw new Error(data.message || "카카오 로그인에 실패했습니다"); } // 토큰 저장 @@ -506,44 +556,46 @@ const LoginScreen = ({navigation}: any) => { await AsyncStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); } if (data.userId) { - await AsyncStorage.setItem('userId', String(data.userId)); + await AsyncStorage.setItem("userId", String(data.userId)); } if (data.membershipType) { - await AsyncStorage.setItem('membershipType', data.membershipType); + await AsyncStorage.setItem("membershipType", data.membershipType); } else { - await AsyncStorage.setItem('membershipType', 'FREE'); + await AsyncStorage.setItem("membershipType", "FREE"); } // 신규 유저/기존 유저 확인 (newUser 값 우선 확인) const isNewUser = data.newUser === true; if (isNewUser) { - navigation.replace('KakaoOnboarding'); + navigation.replace("KakaoOnboarding"); } else { - navigation.replace('Main'); + navigation.replace("Main"); } } catch (error: any) { - console.error('카카오 로그인 처리 실패:', error); - const errorMessage = error.message || '카카오 로그인에 실패했습니다'; - if (typeof window !== 'undefined' && (window as any).alert) { + 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); + Alert.alert("로그인 실패", errorMessage); } } finally { setLoading(false); } } - } else if (result.type === 'cancel') { - console.log('⚠️ [카카오 로그인] 사용자가 취소함'); - Alert.alert('알림', '카카오 로그인이 취소되었습니다.'); + } else if (result.type === "cancel") { + console.log("⚠️ [카카오 로그인] 사용자가 취소함"); + Alert.alert("알림", "카카오 로그인이 취소되었습니다."); } else { - console.log('⚠️ [카카오 로그인] 예상치 못한 결과:', result); - Alert.alert('오류', '카카오 로그인에 실패했습니다. 다시 시도해주세요.'); + console.log("⚠️ [카카오 로그인] 예상치 못한 결과:", result); + Alert.alert("오류", "카카오 로그인에 실패했습니다. 다시 시도해주세요."); } } catch (error: any) { - console.error('❌ [카카오 로그인] 에러:', error); - const errorMessage = error.message || '카카오 로그인 페이지를 열 수 없습니다.'; - Alert.alert('오류', errorMessage); + console.error("❌ [카카오 로그인] 에러:", error); + const errorMessage = + error.message || "카카오 로그인 페이지를 열 수 없습니다."; + Alert.alert("오류", errorMessage); } finally { setLoading(false); } @@ -552,7 +604,8 @@ const LoginScreen = ({navigation}: any) => { return ( + behavior={Platform.OS === "ios" ? "padding" : "height"} + > @@ -565,7 +618,7 @@ const LoginScreen = ({navigation}: any) => { style={[styles.input, errors.username && styles.inputError]} placeholder="아이디" value={formData.username} - onChangeText={text => handleChange('username', text)} + onChangeText={(text) => handleChange("username", text)} autoCapitalize="none" placeholderTextColor={colors.textLight} /> @@ -579,7 +632,7 @@ const LoginScreen = ({navigation}: any) => { style={[styles.input, errors.password && styles.inputError]} placeholder="비밀번호" value={formData.password} - onChangeText={text => handleChange('password', text)} + onChangeText={(text) => handleChange("password", text)} secureTextEntry placeholderTextColor={colors.textLight} /> @@ -588,10 +641,11 @@ const LoginScreen = ({navigation}: any) => { )} - + disabled={loading} + > {loading ? ( ) : ( @@ -601,14 +655,15 @@ const LoginScreen = ({navigation}: any) => { - navigation.navigate('FindId')}> + navigation.navigate("FindId")}> 아이디 찾기 navigation.navigate('ResetPassword')}> + onPress={() => navigation.navigate("ResetPassword")} + > 비밀번호 재설정 - navigation.navigate('Signup')}> + navigation.navigate("Signup")}> 회원가입 @@ -625,31 +680,31 @@ const LoginScreen = ({navigation}: any) => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#252525', + backgroundColor: "#252525", }, scrollContent: { flexGrow: 1, }, loginContainer: { flex: 1, - width: '100%', + width: "100%", paddingTop: 120, paddingHorizontal: 20, - alignItems: 'center', + alignItems: "center", }, logoContainer: { - alignItems: 'center', + alignItems: "center", marginBottom: 30, }, logo: { fontSize: 40, - fontWeight: '800', - fontStyle: 'italic', - color: '#e3ff7c', + fontWeight: "800", + fontStyle: "italic", + color: "#e3ff7c", letterSpacing: 0, }, form: { - width: '100%', + width: "100%", maxWidth: 360, marginBottom: 30, gap: 20, @@ -658,31 +713,31 @@ const styles = StyleSheet.create({ gap: 8, }, input: { - width: '100%', + width: "100%", height: 60, - backgroundColor: '#434343', + backgroundColor: "#434343", borderWidth: 0, borderRadius: 20, paddingHorizontal: 20, fontSize: 16, - fontWeight: '400', + fontWeight: "400", color: colors.text, }, inputError: { borderColor: colors.error, }, errorMessage: { - color: '#ff6b6b', + color: "#ff6b6b", fontSize: 14, marginLeft: 5, }, loginBtn: { - width: '100%', + width: "100%", height: 60, - backgroundColor: '#434343', + backgroundColor: "#434343", borderRadius: 100, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", opacity: 0.6, }, loginBtnDisabled: { @@ -691,36 +746,35 @@ const styles = StyleSheet.create({ loginBtnText: { color: colors.text, fontSize: 18, - fontWeight: '400', + fontWeight: "400", }, links: { - flexDirection: 'row', - justifyContent: 'center', + flexDirection: "row", + justifyContent: "center", gap: 24, - width: '100%', + width: "100%", maxWidth: 360, marginBottom: 30, }, linkText: { color: colors.text, fontSize: 16, - fontWeight: '400', + fontWeight: "400", }, kakaoBtn: { - width: '100%', + width: "100%", maxWidth: 360, height: 50, - backgroundColor: '#ffe617', + backgroundColor: "#ffe617", borderRadius: 20, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, kakaoBtnText: { - color: '#47292b', + color: "#47292b", fontSize: 16, - fontWeight: '400', + 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 09ec898..0e83c86 100644 --- a/src/screens/diet/MealRecommendScreen.tsx +++ b/src/screens/diet/MealRecommendScreen.tsx @@ -20,6 +20,8 @@ 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 = [ @@ -590,9 +592,8 @@ const transformTempMealToUI = (tempDay: any, dayIndex: number) => { const MealRecommendScreen = () => { const navigation = useNavigation(); - const [screen, setScreen] = useState< - "welcome" | "excludedIngredients" | "meals" - >("welcome"); + const [screen, setScreen] = useState<"welcome" | "meals">("welcome"); + const [showDislikedModal, setShowDislikedModal] = useState(false); const [weeklyMeals, setWeeklyMeals] = useState([]); const [currentDay, setCurrentDay] = useState(0); @@ -821,116 +822,20 @@ const MealRecommendScreen = () => { Alert.alert("성공", "오늘의 맞춤 식단이 생성되었습니다! 🎉"); } catch (error: any) { console.error("❌ 1일 식단 추천 실패:", error); - + // 500 에러인 경우 더 친절한 메시지 let errorMessage = error.message || "식단을 불러오는데 실패했습니다."; if (error.status === 500) { - errorMessage = "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; + errorMessage = + "서버에 일시적인 문제가 발생했습니다.\n잠시 후 다시 시도해주세요."; } - - Alert.alert("오류", errorMessage); - } 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 || "식재료 추가에 실패했습니다."); + Alert.alert("오류", errorMessage); } 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) => { @@ -1293,7 +1198,7 @@ const MealRecommendScreen = () => { {/* 3. 금지 식재료 관리 */} setScreen("excludedIngredients")} + onPress={() => setShowDislikedModal(true)} // 변경 activeOpacity={0.8} > { ); } - 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 ( 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("추천된 식단 데이터가 없습니다."); @@ -384,42 +641,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} + + + ))} + + + + )} + @@ -521,13 +950,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} @@ -629,216 +1052,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")} + > + 다시 설정하기 + + + + ); @@ -978,6 +1544,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", @@ -1073,6 +1701,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/RoutineRecommendNewScreen.tsx b/src/screens/exercise/RoutineRecommendNewScreen.tsx index b679ce3..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, @@ -18,7 +17,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 +28,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 +293,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 +380,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 +569,445 @@ 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(); + + // ------------------------------------------------------------ + // 🅰️ [CASE 1] 7일 추천 모드일 때 (서버 저장 + TEMP 요약 저장) + // ------------------------------------------------------------ + if (isWeeklyMode && weeklyRoutine.length > 0) { + setLoading(true); + + 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 || "", + })); + + return { + date: dateStr, + exercises: exercisesPayload, + // TEMP 저장을 위해 원본 데이터도 잠시 들고 있음 (서버 전송시엔 제거됨) + _originalPlan: dayPlan, + }; + }) + .filter((day) => day !== null); + + if (serverPayload.length === 0) { + Alert.alert("알림", "저장할 수 있는 유효한 운동 데이터가 없습니다."); + setLoading(false); + return; + } + + // 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); + }); + + await Promise.all(summaryPromises); + console.log("✅ 7일치 요약(TEMP) 저장 완료!"); + + // 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, + 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, + }; + } + ); + + navigation.navigate("Stats", { + screen: "Exercise", + params: { + recommendedExercises: activitiesToSave, + }, + }); + } catch (error: any) { + console.error("❌ 1일 추천 저장 실패:", error); + Alert.alert("오류", "저장 중 문제가 발생했습니다."); + } finally { + setLoading(false); + } + } }; const addRoutineToActivities = async (routine: any) => { @@ -731,6 +1180,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/RoutineRecommendScreen.tsx b/src/screens/exercise/RoutineRecommendScreen.tsx index f05395c..05e787a 100644 --- a/src/screens/exercise/RoutineRecommendScreen.tsx +++ b/src/screens/exercise/RoutineRecommendScreen.tsx @@ -1,495 +1,692 @@ -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, + 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; + target?: string; + sets?: number; + reps?: number; + weight?: number; + date?: string; +}; + +// 운동 플랜 타입 정의 +type ExercisePlan = { + planId: string; + planName: string; + createdAt: string; + description?: string; + days: Exercise[][]; // 날짜별 운동 배열 (Day 1, Day 2...) + 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 [refreshing, setRefreshing] = useState(false); + + const [isEditMode, setIsEditMode] = useState(false); + const [selectedPlanIds, setSelectedPlanIds] = useState([]); + + // 🔄 데이터 로드 함수 (서버 API 연동) + // 데이터 로드 함수 + // 🔄 데이터 로드 함수 (1일 + 7일 통합) + const loadExercisePlans = async () => { + try { + setLoading(true); + + 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 {}; + }), + ]); + + 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]; + } + + // ------------------------------------------------------------ + // 🅱️ [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); + } + } + + // ------------------------------------------------------------ + // 🏁 최종 합치기 및 정렬 + // ------------------------------------------------------------ + // 최신순 정렬 (생성일 기준 내림차순) + combinedPlans.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + 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(); + setIsEditMode(false); + setSelectedPlanIds([]); + }, []) + ); + + const handlePlanClick = (plan: ExercisePlan) => { + if (isEditMode) { + togglePlanSelection(plan.planId); + return; + } + setSelectedPlan(plan); + setSelectedDay(0); + }; + + const handleBack = () => { + setSelectedPlan(null); + setSelectedDay(0); + }; + + 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(); + if (profile.membershipType === "FREE") { + navigation.navigate("TempRoutineRecommendScreen"); + } else { + navigation.navigate("RoutineRecommendNew"); + } + } catch (error) { + navigation.navigate("TempRoutineRecommendScreen"); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (plan: ExercisePlan) => { + Alert.alert("알림", "서버 삭제 기능이 아직 연동되지 않았습니다."); + }; + + const handleBulkDelete = async () => { + Alert.alert("알림", "서버 삭제 기능이 아직 연동되지 않았습니다."); + }; + + 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 && !refreshing && ( + + + 불러오는 중... + + )} + + + } + > + {!selectedPlan ? ( + plans.length === 0 ? ( + + + 저장된 추천 내역이 없습니다. + navigation.navigate("RoutineRecommendNew")} + > + + 추천받으러 가기 → + + + + ) : ( + + {isEditMode && ( + + + + 전체 선택 + + + + 삭제 + + + )} + + {!isEditMode && ( + + + + 새 운동 추천받기 + + + )} + + {plans.map((plan) => ( + handlePlanClick(plan)} + activeOpacity={0.98} + > + {isEditMode && ( + + + + )} + + + + {plan.days.length > 1 ? "🗓️" : "💪"} + + + {plan.planName} + + {new Date(plan.createdAt).toLocaleDateString("ko-KR")} + + + + {!isEditMode && ( + { + e.stopPropagation(); + handleDelete(plan); + }} + style={styles.deleteBtn} + > + + + )} + + + + {plan.description} + + + 1 + ? styles.exerciseCountBadge + : styles.badge, + ]} + > + 1 && + styles.exerciseCountBadgeText, + ]} + > + {plan.days.length > 1 + ? `📅 ${plan.days.length}일 루틴` + : "1일 루틴"} + + + + + + ))} + + ) + ) : ( + + + ← 목록으로 + + + {selectedPlan.planName} + + {selectedPlan.description} + + + + {/* 7일 루틴일 경우에만 상단 탭 표시 */} + {selectedPlan.days.length > 1 && ( + + {selectedPlan.days.map((_, index) => ( + setSelectedDay(index)} + > + + {index + 1}일차 + + + ))} + + )} + + + {currentDayExercises.length === 0 ? ( + + 운동이 없습니다 + + ) : ( + currentDayExercises.map((exercise, index) => ( + + + 💪 + + + {exercise.name} + + {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, + }, + 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", + }, + 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" }, + 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 }, + detailPlanName: { + fontSize: 20, + fontWeight: "700", + color: "#ffffff", + marginBottom: 4, + }, + 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" }, + exerciseDetail: { fontSize: 14, color: "#aaaaaa" }, +}); + +export default RoutineRecommendScreen; diff --git a/src/screens/exercise/TempRoutineRecommendScreen.tsx b/src/screens/exercise/TempRoutineRecommendScreen.tsx index b218151..08cf506 100644 --- a/src/screens/exercise/TempRoutineRecommendScreen.tsx +++ b/src/screens/exercise/TempRoutineRecommendScreen.tsx @@ -1,1812 +1,1845 @@ -// 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 [todayMetrics, setTodayMetrics] = useState<{ + duration: number; + calories: number; + }>(); + 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 () => { + // 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, + 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("📝 무료 회원 요약(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, + }; + }); + + 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 = () => { + 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 ? ( + + + + 이번 주 추천 완료 + + + ) : ( + + + + 1일 무료 루틴 받기 + + + )} + + + {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 630eb67..fc6ad29 100644 --- a/src/screens/main/MyPageScreen.tsx +++ b/src/screens/main/MyPageScreen.tsx @@ -18,8 +18,6 @@ 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"; @@ -185,17 +183,17 @@ const MyPageScreen = ({ navigation }: any) => { const handleDeleteAccount = async () => { // 프로필 데이터에서 카카오 사용자 여부 확인 - const isKakaoUser = profileData && ( - (profileData as any).loginType === "KAKAO" || - (profileData as any).provider === "KAKAO" || - (profileData as any).kakaoId !== undefined - ); + const isKakaoUser = + profileData && + ((profileData as any).loginType === "KAKAO" || + (profileData as any).provider === "KAKAO" || + (profileData as any).kakaoId !== undefined); if (isKakaoUser) { // 카카오 사용자: unlink API 호출 try { await authAPI.kakaoUnlink(); - + // 성공하면 카카오 사용자 → 탈퇴 완료 Alert.alert( "탈퇴 완료", @@ -218,7 +216,10 @@ const MyPageScreen = ({ navigation }: any) => { ); } catch (error: any) { console.error("카카오 unlink 실패:", error); - Alert.alert("오류", error.message || "카카오 연결 해제에 실패했습니다."); + Alert.alert( + "오류", + error.message || "카카오 연결 해제에 실패했습니다." + ); } } else { // 일반 사용자: 회원탈퇴 모달 열기 @@ -538,22 +539,13 @@ const MyPageScreen = ({ navigation }: any) => { navigation={navigation} /> - {/* ⚠️ 중복되었던 PaymentMethodModal 제거됨 */} - setIsProfileEditModalOpen(false)} profileData={profileData} onProfileUpdate={fetchProfile} /> - setIsRoutineRecommendModalOpen(false)} - /> - setIsMealRecommendModalOpen(false)} - /> + setIsDeleteAccountModalOpen(false)} diff --git a/src/services/authAPI.ts b/src/services/authAPI.ts index b6fc54b..c810487 100644 --- a/src/services/authAPI.ts +++ b/src/services/authAPI.ts @@ -745,7 +745,10 @@ export const authAPI = { // userId 저장 if (response.userId) { await AsyncStorage.setItem("userId", String(response.userId)); - console.log("[AUTH] 카카오 로그인 - userId 저장 완료:", response.userId); + console.log( + "[AUTH] 카카오 로그인 - userId 저장 완료:", + response.userId + ); } // JWT에서도 userId 추출 시도 @@ -784,9 +787,12 @@ export const authAPI = { */ kakaoLogout: async (): Promise<{ message: string }> => { try { - const response = await request<{ message: string }>("/api/auth/kakao/logout", { - method: "POST", - }); + const response = await request<{ message: string }>( + "/api/auth/kakao/logout", + { + method: "POST", + } + ); return response; } catch (error: any) { console.error("[AUTH] 카카오 로그아웃 실패:", error); @@ -799,9 +805,12 @@ export const authAPI = { */ kakaoUnlink: async (): Promise<{ message: string }> => { try { - const response = await request<{ message: string }>("/api/auth/kakao/unlink", { - method: "DELETE", - }); + const response = await request<{ message: string }>( + "/api/auth/kakao/unlink", + { + method: "DELETE", + } + ); return response; } catch (error: any) { console.error("[AUTH] 카카오 연결 해제 실패:", 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..67b3ebb 100644 --- a/src/services/recommendedExerciseAPI.ts +++ b/src/services/recommendedExerciseAPI.ts @@ -1,201 +1,316 @@ -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, requestAI, ACCESS_TOKEN_KEY } from "./apiConfig"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +/** + * 운동 루틴 추천 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; + } + }, + /** + * [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 + */ + 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; + } + }, + + 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; // 에러를 상위로 던져서 저장 프로세스를 중단할지 결정 + } + }, +}; diff --git a/src/services/recommendedMealAPI.ts b/src/services/recommendedMealAPI.ts index 97f67b3..a72e6cd 100644 --- a/src/services/recommendedMealAPI.ts +++ b/src/services/recommendedMealAPI.ts @@ -62,12 +62,14 @@ export const recommendedMealAPI = { } console.error("❌ 임시 식단 생성/조회 실패:", error); - + // 500 에러인 경우 더 명확한 메시지 제공 if (error.status === 500) { - throw new Error("서버에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); + throw new Error( + "서버에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요." + ); } - + throw new Error(error.message || "식단 생성에 실패했습니다."); } }, 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 || "금지 식단 저장에 실패했습니다."); + } + }, +}; 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 +};