diff --git a/src/components/common/InBodyCalendarModal.tsx b/src/components/common/InBodyCalendarModal.tsx index 3297d08..7ec5649 100644 --- a/src/components/common/InBodyCalendarModal.tsx +++ b/src/components/common/InBodyCalendarModal.tsx @@ -206,7 +206,7 @@ const InBodyCalendarModal: React.FC = ({ // 단, 이미 선택된 날짜는 항상 선택 가능 (현재 보고 있는 날짜) const isSelectable = onlySelectableDates ? (isInBodyDate || isSelected) // 기록이 있거나 이미 선택된 날짜는 선택 가능 - : (inBodyDates.length === 0 || isInBodyDate); + : true; // 수기 입력 모드에서는 모든 날짜(과거, 현재, 미래) 선택 가능 // 디버깅: 기록이 있는 날짜 확인 if (__DEV__ && isInBodyDate && date.getDate() <= 3) { diff --git a/src/components/common/InBodyManualForm.tsx b/src/components/common/InBodyManualForm.tsx index 1f41673..5e9a911 100644 --- a/src/components/common/InBodyManualForm.tsx +++ b/src/components/common/InBodyManualForm.tsx @@ -79,6 +79,7 @@ const InBodyManualForm: React.FC = ({ const ageInputRef = useRef(null); const ageTextRef = useRef(""); const inputRefs = useRef<{ [key: string]: TextInput | null }>({}); + const [ageValue, setAgeValue] = useState(""); // 날짜를 YYYY-MM-DD 형식으로 자동 포맷팅 const formatDate = (text: string): string => { @@ -157,6 +158,7 @@ const InBodyManualForm: React.FC = ({ setV((s) => ({ ...s, ...(defaultValues as any) })); if (typeof defaultValues.age === "string") { ageTextRef.current = defaultValues.age; + setAgeValue(defaultValues.age); } } else { console.log("[INBODY FORM] defaultValues 없음 또는 유효하지 않음:", defaultValues); @@ -193,7 +195,57 @@ const InBodyManualForm: React.FC = ({ return (w / (m * m)).toFixed(1); }, [v.height, v.weight]); + // 필수 필드 검증 + const isFormValid = useMemo(() => { + // 체중, 체지방률, 신장, 나이, 성별, 골격근량 + const basicFieldsValid = + v.weight?.trim() !== "" && + v.pbf?.trim() !== "" && + v.height?.trim() !== "" && + ageValue?.trim() !== "" && + v.gender !== "" && + v.smm?.trim() !== ""; + + // 부위별 체지방량 (5개 모두) + const fatFieldsValid = + v.rArmFat?.trim() !== "" && + v.lArmFat?.trim() !== "" && + v.trunkFat?.trim() !== "" && + v.rLegFat?.trim() !== "" && + v.lLegFat?.trim() !== ""; + + // 부위별 근육량 (5개 모두) + const muscleFieldsValid = + v.rArm?.trim() !== "" && + v.lArm?.trim() !== "" && + v.trunk?.trim() !== "" && + v.rLeg?.trim() !== "" && + v.lLeg?.trim() !== ""; + + return basicFieldsValid && fatFieldsValid && muscleFieldsValid; + }, [ + v.weight, + v.pbf, + v.height, + ageValue, + v.gender, + v.smm, + v.rArmFat, + v.lArmFat, + v.trunkFat, + v.rLegFat, + v.lLegFat, + v.rArm, + v.lArm, + v.trunk, + v.rLeg, + v.lLeg, + ]); + const handleSubmit = () => { + if (!isFormValid) { + return; + } const payload = { ...v, age: ageTextRef.current, @@ -244,7 +296,9 @@ const InBodyManualForm: React.FC = ({ {/* 성별 */} - 성별 + + 성별 * + = ({ {/* 나이 (비제어) */} - 나이 + + 나이 * + = ({ defaultValue={v.age} onChangeText={(text) => { ageTextRef.current = text; + setAgeValue(text); }} keyboardType="number-pad" // ✅ 수정: InputAccessoryView 표시를 위해 필수 @@ -297,7 +354,9 @@ const InBodyManualForm: React.FC = ({ {/* 신장 */} - 신장 + + 신장 * + = ({ {/* 체중 */} - 체중 + + 체중 * + = ({ {/* 골격근량 */} - 골격근량(SMM) + + 골격근량(SMM) * + = ({ {/* 체지방률 */} - 체지방률(PBF) + + 체지방률(PBF) * + = ({ { key: "lLeg", label: "왼다리" }, ].map(({ key, label }) => ( - {label} + + {label} * + = ({ { key: "lLegFat", label: "왼다리 체지방" }, ].map(({ key, label }) => ( - {label} + + {label} * + = ({ {/* 저장 버튼 */} - - 저장 + + + 저장 + @@ -553,7 +628,8 @@ const InBodyManualForm: React.FC = ({ onClose={() => setCalendarVisible(false)} onSelectDate={handleDateSelect} selectedDate={v.date ? new Date(v.date) : new Date()} - inBodyDates={[]} + inBodyDates={normalizedInBodyDates || []} + onlySelectableDates={false} // 수기 입력 모드에서는 모든 날짜 선택 가능 /> ); @@ -685,11 +761,22 @@ const styles = StyleSheet.create({ backgroundColor: "#d6ff4b", alignItems: "center", }, + submitDisabled: { + backgroundColor: "#444444", + opacity: 0.5, + }, submitText: { color: "#111111", fontWeight: "700", fontSize: 14, }, + submitTextDisabled: { + color: "#888888", + }, + required: { + color: "#ff4444", + fontSize: 12, + }, // iOS 숫자패드 상단 액세서리 바 bar: { backgroundColor: "#1f1f1f", diff --git a/src/components/modals/InBodyPhotoModal.tsx b/src/components/modals/InBodyPhotoModal.tsx index 16aa87a..36fd582 100644 --- a/src/components/modals/InBodyPhotoModal.tsx +++ b/src/components/modals/InBodyPhotoModal.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useState, useEffect} from 'react'; import { View, Text, @@ -27,6 +27,14 @@ const InBodyPhotoModal: React.FC = ({ const [selectedFile, setSelectedFile] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + // 모달이 열릴 때마다 상태 초기화 + useEffect(() => { + if (isOpen) { + setSelectedFile(null); + setIsProcessing(false); + } + }, [isOpen]); + const requestPermissions = async () => { const {status: cameraStatus} = await ImagePicker.requestCameraPermissionsAsync(); diff --git a/src/screens/analysis/AnalysisScreen.tsx b/src/screens/analysis/AnalysisScreen.tsx index c15f1b7..67ff54c 100644 --- a/src/screens/analysis/AnalysisScreen.tsx +++ b/src/screens/analysis/AnalysisScreen.tsx @@ -2118,14 +2118,6 @@ const AnalysisScreen = ({ navigation }: any) => { style={styles.content} contentContainerStyle={styles.contentContainer} > - {/* 인사말 섹션 */} - - - {displayName} - {` ${greetingSummary}`} - - - {/* 건강점수 섹션 */} { const [completionSummaryTitle, setCompletionSummaryTitle] = useState(""); const [todayTotalWorkoutSeconds, setTodayTotalWorkoutSeconds] = useState(0); const [workoutStartTime, setWorkoutStartTime] = useState(null); // 운동 시작 시점 (초 단위) + const [currentExerciseStartTime, setCurrentExerciseStartTime] = useState(null); // 현재 운동의 시작 시간 (초 단위) + const [currentExerciseStartTimestamp, setCurrentExerciseStartTimestamp] = useState(null); // 현재 운동의 시작 타임스탬프 (밀리초) const [isSavingCompletionTitle, setIsSavingCompletionTitle] = useState(false); const [expandedSavedTitle, setExpandedSavedTitle] = useState( null @@ -1200,6 +1202,12 @@ const ExerciseScreen = ({ navigation }: any) => { // 각 운동별로 Activity 생성 exerciseMap.forEach((records, exerciseName) => { const activityKey = `${session.sessionId}__${exerciseName}`; + + // 이미 추가된 활동이면 스킵 (중복 방지) + if (serverActivityKeys.has(activityKey)) { + return; + } + serverActivityKeys.add(activityKey); const firstRecord = records[0]; @@ -1257,112 +1265,87 @@ const ExerciseScreen = ({ navigation }: any) => { }); }); - // 서버에서 가져온 그룹의 제목들 수집 (어떤 제목들이 서버에 있는지 확인) - const serverTitles = new Set(); - mergedGroups.forEach((group) => { - const normalizedTitle = (group.title || "").trim().toLowerCase(); - if (normalizedTitle) { - serverTitles.add(normalizedTitle); - } - }); - - // 기존 allActivities에서 이미 존재하는 활동의 키 수집 (중복 방지용) - const existingActivityKeys = new Set(); - prev.forEach((activity) => { - if (activity.date === currentDateStr && activity.sessionId && activity.name) { - const key = `${activity.sessionId}__${activity.name}`; - existingActivityKeys.add(key); + // 서버에서 가져온 활동의 고유 키 생성 (날짜 + 제목 + 운동명) - 중복 체크용 + const serverActivityDedupKeys = new Set(); + newActivities.forEach((activity) => { + if (activity.date && activity.saveTitle && activity.name) { + const normalizedTitle = (activity.saveTitle || "").trim().toLowerCase(); + const key = `${activity.date}__${normalizedTitle}__${activity.name.trim()}`; + serverActivityDedupKeys.add(key); } }); // 기존 allActivities에서: // 1. 다른 날짜의 활동은 유지 - // 2. 현재 날짜의 saveTitle이 있고 sessionId가 있는 활동 중: - // - 서버에 같은 제목이 있으면 유지 (같은 제목으로 저장된 운동) - // - 서버에 같은 제목이 없으면 제거 (다른 제목으로 새로 저장되었으므로) - // 3. 현재 날짜의 sessionId가 서버에 있지만 기존에 saveTitle이 없는 활동은 제거 (서버 데이터로 대체) - // 4. 현재 날짜의 saveTitle이 없고 sessionId도 서버에 없는 활동은 유지 (아직 저장되지 않은 활동) + // 2. 현재 날짜의 sessionId가 있는 활동은 모두 제거 (서버 데이터로 대체) + // 3. 현재 날짜의 sessionId가 없는 활동은 서버 데이터와 중복되지 않으면 유지 const filteredPrev = prev.filter((activity) => { // 다른 날짜의 활동은 유지 if (activity.date !== currentDateStr) { return true; } - // saveTitle이 있고 sessionId가 있는 경우 - if (activity.saveTitle && activity.sessionId) { - const normalizedActivityTitle = activity.saveTitle.trim().toLowerCase(); - // 서버에 같은 제목이 있으면 유지 (같은 제목으로 저장된 운동) - if (serverTitles.has(normalizedActivityTitle)) { - return true; - } - // 서버에 같은 제목이 없으면 제거 (다른 제목으로 새로 저장되었으므로) - return false; - } - - // sessionId가 있고 서버에 있으면 제거 (서버 데이터로 대체) - if (activity.sessionId && serverSessionIds.has(activity.sessionId)) { + // sessionId가 있는 경우는 모두 제거 (서버 데이터로 대체) + if (activity.sessionId) { return false; } - // sessionId + exerciseName 조합이 서버에 있으면 제거 - if (activity.sessionId && activity.name) { - const activityKey = `${activity.sessionId}__${activity.name}`; - if (serverActivityKeys.has(activityKey)) { - return false; + // sessionId가 없으면 서버 데이터와 중복 체크 + // 같은 날짜, 같은 제목, 같은 운동명이 서버에 있으면 제거 (서버 데이터로 대체) + if (activity.date && activity.saveTitle && activity.name) { + const normalizedTitle = (activity.saveTitle || "").trim().toLowerCase(); + const key = `${activity.date}__${normalizedTitle}__${activity.name.trim()}`; + if (serverActivityDedupKeys.has(key)) { + return false; // 서버 데이터와 중복이면 제거 } } - // 나머지는 유지 (아직 저장되지 않은 활동) + // 서버 데이터와 중복되지 않으면 유지 (아직 저장되지 않은 새로운 활동) return true; }); - // 서버 데이터에서 이미 존재하는 활동은 제외 (중복 방지) - const newServerActivities = newActivities.filter((activity) => { - if (!activity.sessionId || !activity.name) return true; - const key = `${activity.sessionId}__${activity.name}`; - // 이미 filteredPrev에 존재하는 활동은 제외 - return !existingActivityKeys.has(key); - }); - - // 서버 데이터 추가 (중복 제거된 것만) - let result = [...filteredPrev, ...newServerActivities]; - - // 중복 제거: 같은 날짜, 같은 saveTitle, 같은 운동명을 가진 활동은 하나만 유지 - // 합치지 않고 첫 번째 것만 유지 (중복 제거) + // 서버 데이터 추가 (중복 제거: 날짜 + 제목 + 운동명 기준) const seenKeys = new Set(); - const deduplicatedResult: Activity[] = []; + const result: Activity[] = []; - result.forEach((activity) => { - if (!activity.name || !activity.date) { - // 이름이나 날짜가 없으면 그대로 추가 - deduplicatedResult.push(activity); + // filteredPrev의 활동들을 먼저 추가하고 seenKeys에 등록 + filteredPrev.forEach((activity) => { + if (activity.date && activity.saveTitle && activity.name) { + const normalizedTitle = (activity.saveTitle || "").trim().toLowerCase(); + const key = `${activity.date}__${normalizedTitle}__${activity.name.trim()}`; + seenKeys.add(key); + } + result.push(activity); + }); + + // 서버에서 가져온 활동 추가 (중복 제거) + newActivities.forEach((activity) => { + if (!activity.date || !activity.saveTitle || !activity.name) { + // 필수 필드가 없으면 추가하지 않음 return; } - // 같은 날짜, 같은 saveTitle, 같은 운동명을 가진 활동은 중복으로 간주 - const normalizedSaveTitle = (activity.saveTitle || "").trim().toLowerCase(); - const dedupeKey = `${activity.date}__${normalizedSaveTitle}__${activity.name.trim()}`; + // 고유 키: 날짜 + 제목 + 운동명 + const normalizedTitle = (activity.saveTitle || "").trim().toLowerCase(); + const uniqueKey = `${activity.date}__${normalizedTitle}__${activity.name.trim()}`; - // 이미 본 운동이면 스킵 (중복 제거) - if (seenKeys.has(dedupeKey)) { + // 이미 추가된 활동이면 스킵 (중복 제거) + if (seenKeys.has(uniqueKey)) { return; } - // 처음 본 운동이면 추가 - seenKeys.add(dedupeKey); - deduplicatedResult.push(activity); + // 처음 본 활동이면 추가 + seenKeys.add(uniqueKey); + result.push(activity); }); - result = deduplicatedResult; - console.log("[EXERCISE][SAVED] 저장된 운동 기록 재구성:", { prevCount: prev.length, filteredPrevCount: filteredPrev.length, newCount: newActivities.length, - newServerActivitiesCount: newServerActivities.length, resultCount: result.length, removedCount: prev.length - filteredPrev.length, - duplicateRemoved: filteredPrev.length + newServerActivities.length - result.length, + addedCount: result.length - filteredPrev.length, activities: newActivities.map((a) => ({ name: a.name, sessionId: a.sessionId, @@ -1905,7 +1888,6 @@ const ExerciseScreen = ({ navigation }: any) => { intensityLength: intensityList.length, feedbackList, feedbackLength: feedbackList.length, - seconds: workoutSeconds, // API 스펙 검증: 배열 길이가 운동 개수와 일치하는지 확인 arraysMatchExerciseCount: intensityList.length === completedExercises.length && @@ -1916,8 +1898,7 @@ const ExerciseScreen = ({ navigation }: any) => { userIdNum, trimmedTitle, intensityList, - feedbackList, - workoutSeconds + feedbackList ); console.log("[WORKOUT][SAVE] 운동 저장 성공:", { @@ -2276,6 +2257,10 @@ const ExerciseScreen = ({ navigation }: any) => { const openExerciseEntry = () => { // 운동 추가 시 시작 시간 기록 (새로운 세션 시작) setWorkoutStartTime(todayTotalWorkoutSeconds); + // 현재 운동의 시작 시간도 기록 (각 운동별 시간 추적) + setCurrentExerciseStartTime(todayTotalWorkoutSeconds); + // 실제 타임스탬프 기록 (실제 운동 시간 계산용) + setCurrentExerciseStartTimestamp(Date.now()); resetStretchFlowState(); setIntroStage("intro"); setIsIntroVisible(false); @@ -2308,6 +2293,10 @@ const ExerciseScreen = ({ navigation }: any) => { // 운동 시작 시점의 누적 시간 기록 (현재 세션 시간 계산을 위해) setWorkoutStartTime(todayTotalWorkoutSeconds); + // 현재 운동의 시작 시간도 기록 (각 운동별 시간 추적) + setCurrentExerciseStartTime(todayTotalWorkoutSeconds); + // 실제 타임스탬프 기록 (실제 운동 시간 계산용) + setCurrentExerciseStartTimestamp(Date.now()); // 아직 완료하지 않은 첫 번째 운동을 찾음 const nextActivity = @@ -2955,6 +2944,56 @@ const ExerciseScreen = ({ navigation }: any) => { )}T${String(now.getHours()).padStart(2, "0")}:${String( now.getMinutes() ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; + + // 각 운동에 소요된 시간 계산 (초 단위) + // 실제 타임스탬프를 사용하여 정확한 운동 시간 계산 + let exerciseSeconds = 0; + + if (currentExerciseStartTimestamp !== null) { + // 실제 타임스탬프 기반으로 경과 시간 계산 (밀리초 → 초) + const elapsedMs = Date.now() - currentExerciseStartTimestamp; + exerciseSeconds = Math.max(1, Math.floor(elapsedMs / 1000)); // 최소 1초 + console.log("[WORKOUT][TIME] 타임스탬프 기반 시간 계산:", { + exerciseName, + startTimestamp: currentExerciseStartTimestamp, + currentTimestamp: Date.now(), + elapsedMs, + exerciseSeconds, + }); + } else if (currentExerciseStartTime !== null && todayTotalWorkoutSeconds !== null) { + // 타임스탬프가 없으면 기존 방식 사용 (하위 호환성) + const calculatedSeconds = Math.max(0, todayTotalWorkoutSeconds - currentExerciseStartTime); + if (calculatedSeconds > 0) { + exerciseSeconds = calculatedSeconds; + } else { + // 계산된 시간이 0이면 최소 10초 (운동을 했다면 최소한의 시간은 있어야 함) + exerciseSeconds = 10; + } + console.log("[WORKOUT][TIME] 누적 시간 기반 계산:", { + exerciseName, + currentExerciseStartTime, + todayTotalWorkoutSeconds, + calculatedSeconds, + exerciseSeconds, + }); + } else { + // 둘 다 없으면 최소 10초 (운동을 했다면 최소한의 시간은 있어야 함) + exerciseSeconds = 10; + console.log("[WORKOUT][TIME] 시간 정보 없음, 기본값 사용:", { + exerciseName, + exerciseSeconds, + }); + } + + console.log("[WORKOUT][TIME] 최종 운동 시간:", { + exerciseName, + exerciseSeconds, + }); + + // 운동 저장 후 현재 운동 시작 시간을 현재 시간으로 업데이트 (다음 운동을 위해) + setCurrentExerciseStartTime(todayTotalWorkoutSeconds); + setCurrentExerciseStartTimestamp(Date.now()); // 다음 운동을 위해 타임스탬프도 업데이트 + const sessionPayload = { exerciseName, category: meta?.category || "기타", @@ -2966,6 +3005,7 @@ const ExerciseScreen = ({ navigation }: any) => { weight: Number(s.weight) || 0, reps: Number(s.reps) || 0, })), + seconds: exerciseSeconds, // 각 운동에 소요된 시간 (초 단위) }; console.log("[WORKOUT][LOCAL_SAVE]", sessionPayload); console.log( @@ -3037,6 +3077,15 @@ const ExerciseScreen = ({ navigation }: any) => { // 해당 달의 월별 데이터 전체 다시 가져오기 loadMonthlyProgress(activeDate.getFullYear(), activeDate.getMonth()); + + // 오늘 날짜인 경우 운동 시간 다시 조회 (서버에서 계산된 총 시간 반영) + if (isToday) { + try { + await loadTodayWorkoutTime(); + } catch (timeError) { + console.error("[WORKOUT][TIME] 운동 시간 재조회 실패:", timeError); + } + } } catch (progressError) { console.error("진행률 조회 실패:", progressError); } @@ -3143,110 +3192,19 @@ const ExerciseScreen = ({ navigation }: any) => { style: "destructive", onPress: async () => { try { - const target = allActivities.find((a) => a.id === workoutId); - const targetDate = target?.date; - + // 서버 API 호출 (sessionId가 있으면) if (sessionId) { - const res = await deleteWorkoutSession(sessionId); - console.log("[WORKOUT][DELETE][OK]", res); + await deleteWorkoutSession(sessionId); + console.log("[WORKOUT][DELETE] 서버 삭제 완료:", sessionId); } - // 운동 삭제 후 해당 날짜의 진행률 다시 가져오기 - if (targetDate) { - try { - const today = new Date(); - const targetDateObj = new Date(targetDate); - const isToday = - targetDateObj.getFullYear() === today.getFullYear() && - targetDateObj.getMonth() === today.getMonth() && - targetDateObj.getDate() === today.getDate(); - - let dateProgress: DailyProgressWeekItem; - if (isToday) { - dateProgress = await fetchTodayProgress(); - } else { - dateProgress = await fetchDateProgress(targetDate); - } - - // 주간 진행률 업데이트 - setWeeklyProgress((prev) => { - const index = prev.findIndex( - (item) => item.date === targetDate - ); - if (index >= 0) { - const updated = [...prev]; - updated[index] = dateProgress; - return updated; - } - return prev; - }); - - // 월별 진행률 업데이트 - setMonthlyProgress((prev) => { - const index = prev.findIndex( - (item) => item.date === targetDate - ); - if (index >= 0) { - const updated = [...prev]; - updated[index] = dateProgress; - return updated; - } - return prev; - }); - - // 해당 달의 월별 데이터 전체 다시 가져오기 - loadMonthlyProgress( - targetDateObj.getFullYear(), - targetDateObj.getMonth() - ); - } catch (progressError) { - console.error("진행률 조회 실패:", progressError); - } - } - } catch (e) { - console.error("[WORKOUT][DELETE][FAIL]", e); - } finally { - const target = allActivities.find((a) => a.id === workoutId); - const updatedActivities = allActivities.filter( - (activity) => activity.id !== workoutId + // UI에서 제거 + setAllActivities((prev) => + prev.filter((activity) => activity.id !== workoutId) ); - setAllActivities(updatedActivities); - - // 이번 주에 완료된 운동 개수 다시 계산 - const today = new Date(); - const thisWeekStart = new Date(today); - thisWeekStart.setDate(today.getDate() - today.getDay()); // 일요일로 설정 - thisWeekStart.setHours(0, 0, 0, 0); - - const thisWeekCompleted = updatedActivities.filter((activity) => { - const activityDate = new Date(activity.date); - activityDate.setHours(0, 0, 0, 0); - return ( - isActivityFullyCompleted(activity) && - activityDate >= thisWeekStart && - activityDate <= today - ); - }).length; - - // 완료 횟수와 칼로리를 즉시 업데이트 - setCompletedCountPersist(thisWeekCompleted); - // 주간 칼로리 다시 계산 (비동기이므로 await) - (async () => { - try { - await loadWeeklyCalories(); - } catch (error) { - console.error( - "[WORKOUT][DELETE] 주간 칼로리 재계산 실패:", - error - ); - } - })(); - - eventBus.emit("workoutSessionDeleted", { - sessionId, - exerciseName: target?.name, - workoutDate: target?.date, - }); + } catch (e) { + console.error("[WORKOUT][DELETE] 삭제 실패:", e); + Alert.alert("오류", "운동 삭제 중 오류가 발생했습니다."); } }, }, @@ -3635,11 +3593,6 @@ const ExerciseScreen = ({ navigation }: any) => { 운동 기록하기 - {savedWorkouts.length > 0 && ( - - 전체 삭제 - - )} {hasIncompleteActivities && workoutActivities.length > 0 ? ( { JSON.stringify(payload, null, 2) ); - // 날짜 검증: 최신 기록보다 과거 날짜로 저장하려는 경우 안내 - if (payload.measurementDate) { - try { - console.log("[INBODY] 날짜 검증 중:", payload.measurementDate); - const latestRecord = await getLatestInBody(); - - if (latestRecord) { - const latestData = latestRecord?.success ? latestRecord.inBody : latestRecord; - const latestDate = latestData?.measurementDate || latestData?.date; - - if (latestDate) { - // 날짜 형식 정규화 (YYYY-MM-DD) - const normalizedLatestDate = normalizeDateForComparison(latestDate); - const normalizedPayloadDate = normalizeDateForComparison(payload.measurementDate); - - // 날짜 비교 (문자열 비교로 날짜 순서 확인) - if (normalizedPayloadDate < normalizedLatestDate) { - // 저장하려는 날짜가 최신 기록보다 과거인 경우 - console.log("[INBODY] 과거 날짜 저장 시도:", { - latestDate: normalizedLatestDate, - payloadDate: normalizedPayloadDate, - }); - - Alert.alert( - "과거 날짜 저장 불가", - `이미 ${latestDate}에 인바디 기록이 있습니다.\n\n과거 날짜로는 인바디를 등록할 수 없습니다.\n\n${payload.measurementDate} 이후의 날짜로 등록해주세요.`, - [{ text: "확인" }] - ); - setLoading(false); - return; - } else if (normalizedPayloadDate > normalizedLatestDate) { - // 미래 날짜는 저장 허용 - console.log("[INBODY] 미래 날짜 저장 허용:", { - latestDate: normalizedLatestDate, - payloadDate: normalizedPayloadDate, - }); - } - } - } - } catch (error: any) { - console.warn("[INBODY] 날짜 검증 중 에러:", error); - // 날짜 검증 실패 시에도 저장은 진행 (에러가 발생해도 저장은 허용) - } - } + // 날짜 검증 제거: 과거 날짜도 저장 가능하도록 변경 // 같은 날짜에 기존 기록이 있는지 확인 // 날짜 변경 시 이미 확인한 ID가 있으면 사용, 없으면 최신 기록으로 확인 diff --git a/src/screens/inbody/InBodyScreen.tsx b/src/screens/inbody/InBodyScreen.tsx index 73a1a66..f23b438 100644 --- a/src/screens/inbody/InBodyScreen.tsx +++ b/src/screens/inbody/InBodyScreen.tsx @@ -7,15 +7,25 @@ import { ScrollView, TouchableOpacity, ActivityIndicator, + Dimensions, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons as Icon } from "@expo/vector-icons"; -import { getLatestInBody, getInBodyByDatePath } from "../../utils/inbodyApi"; +import { LineChart, BarChart } from "react-native-chart-kit"; +import { + getLatestInBody, + getInBodyByDatePath, + getInBodyComment, + getInBodyList, + getInBodyHistory, + InBodyHistoryItem, +} from "../../utils/inbodyApi"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { eventBus } from "../../utils/eventBus"; import InbodyDateNavigator from "../../components/common/InbodyDateNavigator"; import InBodyCalendarModal from "../../components/common/InBodyCalendarModal"; import { ROUTES } from "../../constants/routes"; +import { ACCESS_TOKEN_KEY } from "../../services/apiConfig"; const InBodyScreen = ({ navigation, route }: any) => { const [inBodyData, setInBodyData] = useState(null); @@ -24,6 +34,11 @@ const InBodyScreen = ({ navigation, route }: any) => { const [selectedDate, setSelectedDate] = useState(null); const [showCalendarModal, setShowCalendarModal] = useState(false); const [availableDates, setAvailableDates] = useState([]); + const [activeTab, setActiveTab] = useState<"info" | "analysis">("info"); + const [aiComment, setAiComment] = useState(""); + const [aiCommentLoading, setAiCommentLoading] = useState(false); + const [historyData, setHistoryData] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); const fromPhotoUpload = route?.params?.fromPhotoUpload; const parseNumericValue = useCallback((value: any): number | undefined => { @@ -148,16 +163,17 @@ const InBodyScreen = ({ navigation, route }: any) => { const data = response?.success ? response.inBody : response; // segmentalFatAnalysis를 여러 위치에서 찾기 (API 수정 대응) - const segmentalFatAnalysis = - data?.segmentalFatAnalysis || - response?.segmentalFatAnalysis || + const segmentalFatAnalysis = + data?.segmentalFatAnalysis || + response?.segmentalFatAnalysis || response?.inBody?.segmentalFatAnalysis || null; // data에 segmentalFatAnalysis가 없으면 추가 - const finalData = segmentalFatAnalysis && !data?.segmentalFatAnalysis - ? { ...data, segmentalFatAnalysis } - : data; + const finalData = + segmentalFatAnalysis && !data?.segmentalFatAnalysis + ? { ...data, segmentalFatAnalysis } + : data; console.log("[INBODY SCREEN] 처리된 날짜별 데이터:", { hasData: !!finalData, @@ -249,16 +265,17 @@ const InBodyScreen = ({ navigation, route }: any) => { const latest = response?.success ? response.inBody : response; // segmentalFatAnalysis를 여러 위치에서 찾기 (API 수정 대응) - const segmentalFatAnalysis = - latest?.segmentalFatAnalysis || - response?.segmentalFatAnalysis || + const segmentalFatAnalysis = + latest?.segmentalFatAnalysis || + response?.segmentalFatAnalysis || response?.inBody?.segmentalFatAnalysis || null; // latest에 segmentalFatAnalysis가 없으면 추가 - const finalLatest = segmentalFatAnalysis && !latest?.segmentalFatAnalysis - ? { ...latest, segmentalFatAnalysis } - : latest; + const finalLatest = + segmentalFatAnalysis && !latest?.segmentalFatAnalysis + ? { ...latest, segmentalFatAnalysis } + : latest; console.log("[INBODY SCREEN] 처리된 latest 데이터:", { hasLatest: !!finalLatest, @@ -576,6 +593,237 @@ const InBodyScreen = ({ navigation, route }: any) => { })(); }, []); + // AI 코멘트 로드 + const loadAiComment = useCallback(async () => { + // 인바디 데이터가 없어도 API 호출 시도 + + try { + setAiCommentLoading(true); + + // user_id 가져오기 (JWT에서) + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + let userId: string | null = null; + if (token) { + try { + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + const payload = JSON.parse(jsonPayload); + userId = payload.sub || payload.userPk?.toString() || null; + console.log("[INBODY][COMMENT] JWT에서 추출한 userId:", { + userId, + userIdType: typeof userId, + payloadSub: payload.sub, + payloadUserPk: payload.userPk, + }); + } catch (e) { + console.error("[INBODY][COMMENT] JWT 디코딩 실패:", e); + } + } + + if (!userId || typeof userId !== "string") { + console.log("[INBODY][COMMENT] user_id 없음 또는 유효하지 않음:", { + userId, + userIdType: typeof userId, + }); + setAiComment(""); + return; + } + + // 인바디 목록 가져오기 (period 계산용) + let period = ""; + try { + const inBodyList = await getInBodyList(); + const records = Array.isArray(inBodyList) + ? inBodyList + : inBodyList?.data || inBodyList?.inBodyList || []; + + if (records.length > 0) { + const dates = records + .map((r: any) => { + const dateStr = r.measurementDate || r.date || r.measurement_date; + if (!dateStr) return null; + return dateStr.replace(/\./g, "-").split("T")[0]; + }) + .filter((d: string | null) => d !== null) + .sort(); + + if (dates.length > 0) { + const firstDate = dates[0]; + const lastDate = dates[dates.length - 1]; + period = `${firstDate} ~ ${lastDate}`; + } + } + } catch (e) { + console.warn("[INBODY][COMMENT] 인바디 목록 조회 실패:", e); + // period 없어도 계속 진행 + } + + // 현재 인바디 데이터에서 latest 정보 추출 (데이터가 없으면 오늘 날짜와 기본값 사용) + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String( + today.getMonth() + 1 + ).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + + const measurementDate = + inBodyData?.measurementDate || + inBodyData?.date || + inBodyData?.measurement_date || + todayStr; + const dateStr = measurementDate + ? measurementDate.replace(/\./g, "-").split("T")[0] + : todayStr; + + const weight = parseNumericValue( + inBodyData?.weight || + inBodyData?.bodyComposition?.weight || + inBodyData?.muscleFatAnalysis?.weight || + 0 + ); + const bodyFatPercentage = parseNumericValue( + inBodyData?.bodyFatPercentage || + inBodyData?.obesityAnalysis?.bodyFatPercentage || + inBodyData?.bodyComposition?.bodyFatPercentage || + inBodyData?.muscleFatAnalysis?.bodyFatPercentage || + inBodyData?.pbf || + 0 + ); + const skeletalMuscleMass = parseNumericValue( + inBodyData?.skeletalMuscleMass || + inBodyData?.bodyComposition?.skeletalMuscleMass || + inBodyData?.muscleFatAnalysis?.skeletalMuscleMass || + inBodyData?.smm || + 0 + ); + + // 숫자 타입 검증 및 유효성 확인 (데이터가 없어도 API 호출 시도) + const finalWeight = + weight !== undefined && !isNaN(weight) && isFinite(weight) ? weight : 0; + const finalBodyFatPercentage = + bodyFatPercentage !== undefined && + !isNaN(bodyFatPercentage) && + isFinite(bodyFatPercentage) + ? bodyFatPercentage + : 0; + const finalSkeletalMuscleMass = + skeletalMuscleMass !== undefined && + !isNaN(skeletalMuscleMass) && + isFinite(skeletalMuscleMass) + ? skeletalMuscleMass + : 0; + const finalDateStr = dateStr || todayStr; + + console.log( + "[INBODY][COMMENT] API 호출 데이터 (인바디 데이터 없을 수 있음):", + { + hasInBodyData: !!inBodyData, + weight: finalWeight, + bodyFatPercentage: finalBodyFatPercentage, + skeletalMuscleMass: finalSkeletalMuscleMass, + dateStr: finalDateStr, + } + ); + + // trend 계산 (일단 기본값으로 처리, 나중에 이전 데이터와 비교하여 계산 가능) + const trend = { + weight: "SAME" as const, + body_fat_percentage: "SAME" as const, + skeletal_muscle_mass: "SAME" as const, + }; + + // period가 없으면 현재 날짜만 사용 + if (!period) { + period = `${finalDateStr} ~ ${finalDateStr}`; + } + + // 숫자 타입 보장 (Number로 명시적 변환) + const request = { + user_id: userId, + period: period, + latest: { + date: finalDateStr, + weight: Number(finalWeight), + body_fat_percentage: Number(finalBodyFatPercentage), + skeletal_muscle_mass: Number(finalSkeletalMuscleMass), + }, + trend: trend, + }; + + console.log("[INBODY][COMMENT] AI 코멘트 요청:", { + user_id: request.user_id, + period: request.period, + latest: { + date: request.latest.date, + weight: request.latest.weight, + body_fat_percentage: request.latest.body_fat_percentage, + skeletal_muscle_mass: request.latest.skeletal_muscle_mass, + }, + trend: request.trend, + // 디버깅 정보는 별도로 출력 + debug: { + weightType: typeof request.latest.weight, + bodyFatPercentageType: typeof request.latest.body_fat_percentage, + skeletalMuscleMassType: typeof request.latest.skeletal_muscle_mass, + }, + }); + const response = await getInBodyComment(request); + + if (response?.comment) { + setAiComment(response.comment); + } else { + setAiComment(""); + } + } catch (error: any) { + console.error("[INBODY][COMMENT] AI 코멘트 로드 실패:", error); + setAiComment(""); + } finally { + setAiCommentLoading(false); + } + }, [inBodyData, parseNumericValue]); + + // 인바디 데이터가 변경될 때마다 AI 코멘트 로드 (데이터가 없어도 호출) + useEffect(() => { + if (activeTab === "info") { + loadAiComment(); + } + }, [inBodyData, activeTab, loadAiComment]); + + // 히스토리 데이터 로드 + const loadHistory = useCallback(async () => { + try { + setHistoryLoading(true); + const data = await getInBodyHistory(); + // 날짜순으로 정렬 (오래된 것부터) + const sorted = [...data].sort((a, b) => { + const dateA = new Date(a.measurementDate).getTime(); + const dateB = new Date(b.measurementDate).getTime(); + return dateA - dateB; + }); + setHistoryData(sorted); + console.log( + "[INBODY][HISTORY] 히스토리 데이터 로드 완료:", + sorted.length + ); + } catch (error: any) { + console.error("[INBODY][HISTORY] 히스토리 로드 실패:", error); + setHistoryData([]); + } finally { + setHistoryLoading(false); + } + }, []); + + // 분석 탭 활성화 시 히스토리 로드 + useEffect(() => { + if (activeTab === "analysis") { + loadHistory(); + } + }, [activeTab, loadHistory]); + const segmentalMuscleItems = useMemo(() => { if (!inBodyData) { return [ @@ -864,438 +1112,723 @@ const InBodyScreen = ({ navigation, route }: any) => { - - {/* 날짜 네비게이터 */} - {inBodyData && selectedDate && ( - - setShowCalendarModal(true)} - > - - - {selectedDate.getFullYear()}. - {String(selectedDate.getMonth() + 1).padStart(2, "0")}. - {String(selectedDate.getDate()).padStart(2, "0")} - - - - {availableDates.length > 0 && ( - - + {/* 탭 버튼 */} + + setActiveTab("info")} + > + + 정보 열람 + + + setActiveTab("analysis")} + > + + 분석 + + + + + {activeTab === "info" ? ( + + {/* AI 코멘트 */} + {inBodyData && ( + + {aiCommentLoading ? ( + + + AI 분석 중... + + ) : aiComment ? ( + + + + AI 분석 + + {aiComment} + + ) : null} + + )} + + {/* 날짜 네비게이터 */} + {inBodyData && selectedDate && ( + + setShowCalendarModal(true)} + > + + + {selectedDate.getFullYear()}. + {String(selectedDate.getMonth() + 1).padStart(2, "0")}. + {String(selectedDate.getDate()).padStart(2, "0")} + + + + {availableDates.length > 0 && ( + + + + )} + + )} + + {loading ? ( + + + 데이터 로딩 중... + + ) : inBodyData ? ( + <> + {/* 기본 정보 */} + + 기본 정보 + + {inBodyData.measurementDate && ( + + 검사일 + + {inBodyData.measurementDate} + + + + )} + {inBodyData.gender && ( + + 성별 + + {inBodyData.gender} + + + + )} + {inBodyData.age && ( + + 나이 + + {extractValue(inBodyData.age)} + + + + )} + {inBodyData.height && ( + + 신장 + + {extractValue(inBodyData.height)} + + cm + + )} + {inBodyData.weight && ( + + 체중 + + {extractValue( + inBodyData.weight || + inBodyData.bodyComposition?.weight || + inBodyData.muscleFatAnalysis?.weight + )} + + kg + + )} + - )} - - )} - - {loading ? ( - - - 데이터 로딩 중... - - ) : inBodyData ? ( - <> - {/* 기본 정보 */} - - 기본 정보 - - {inBodyData.measurementDate && ( + + {/* 핵심 수치 */} + + 핵심 수치 + + + 골격근량 + + {extractValue( + inBodyData.skeletalMuscleMass || + inBodyData.muscleFatAnalysis?.skeletalMuscleMass + )} + + kg + + + 체지방량 + + {extractValue( + inBodyData.bodyFatMass || + inBodyData.muscleFatAnalysis?.bodyFatMass || + inBodyData.bodyComposition?.bodyFatMass + )} + + kg + - 검사일 + 체지방률 - {inBodyData.measurementDate} + {extractValue( + inBodyData.bodyFatPercentage || + inBodyData.obesityAnalysis?.bodyFatPercentage + )} - + % - )} - {inBodyData.gender && ( - 성별 - {inBodyData.gender} - + BMI + + {extractValue( + inBodyData.bmi || inBodyData.obesityAnalysis?.bmi + )} + + kg/m² - )} - {inBodyData.age && ( + {inBodyData.visceralFatLevel && ( + + 내장지방 레벨 + + {extractValue(inBodyData.visceralFatLevel)} + + + + )} + + + + {/* 체성분 분석 */} + + 체성분 분석 + - 나이 + 체수분 - {extractValue(inBodyData.age)} + {extractValue(inBodyData.bodyComposition?.totalBodyWater)} + + + {extractRange(inBodyData.bodyComposition?.totalBodyWater)} - - )} - {inBodyData.height && ( - 신장 + 단백질 - {extractValue(inBodyData.height)} + {extractValue(inBodyData.bodyComposition?.protein)} + + + {extractRange(inBodyData.bodyComposition?.protein)} + + + + 무기질 + + {extractValue(inBodyData.bodyComposition?.mineral)} + + + {extractRange(inBodyData.bodyComposition?.mineral)} - cm - )} - {inBodyData.weight && ( - 체중 + 체지방 - {extractValue( - inBodyData.weight || - inBodyData.bodyComposition?.weight || - inBodyData.muscleFatAnalysis?.weight - )} + {extractValue(inBodyData.bodyComposition?.bodyFatMass)} + + + {extractRange(inBodyData.bodyComposition?.bodyFatMass)} - kg - )} + - - {/* 핵심 수치 */} - - 핵심 수치 - - - 골격근량 - - {extractValue( - inBodyData.skeletalMuscleMass || - inBodyData.muscleFatAnalysis?.skeletalMuscleMass + {/* 골격근 지방 분석 */} + + 골격근 지방 분석 + + + 표준이하 + 표준 + 표준이상 + + - kg - - - 체지방량 - - {extractValue( - inBodyData.bodyFatMass || - inBodyData.muscleFatAnalysis?.bodyFatMass || - inBodyData.bodyComposition?.bodyFatMass + percentage={resolveBarPercentage( + inBodyData.bodyComposition?.totalBodyWater, + "표준" )} - - kg - - - 체지방률 - - {extractValue( - inBodyData.bodyFatPercentage || - inBodyData.obesityAnalysis?.bodyFatPercentage + status="표준" + /> + - % - - - BMI - - {extractValue( - inBodyData.bmi || inBodyData.obesityAnalysis?.bmi + status={ + inBodyData.muscleFatAnalysis?.skeletalMuscleStatus || + "표준" + } + /> + - kg/m² + status={ + inBodyData.muscleFatAnalysis?.bodyFatStatus || "표준" + } + isLast + /> - {inBodyData.visceralFatLevel && ( - - 내장지방 레벨 - - {extractValue(inBodyData.visceralFatLevel)} - - - - )} - - {/* 체성분 분석 */} - - 체성분 분석 - - - 체수분 - - {extractValue(inBodyData.bodyComposition?.totalBodyWater)} - - - {extractRange(inBodyData.bodyComposition?.totalBodyWater)} - - - - 단백질 - - {extractValue(inBodyData.bodyComposition?.protein)} - - - {extractRange(inBodyData.bodyComposition?.protein)} - - - - 무기질 - - {extractValue(inBodyData.bodyComposition?.mineral)} - - - {extractRange(inBodyData.bodyComposition?.mineral)} - - - - 체지방 - - {extractValue(inBodyData.bodyComposition?.bodyFatMass)} - - - {extractRange(inBodyData.bodyComposition?.bodyFatMass)} - + {/* 비만 분석 */} + + 비만 분석 + + + 표준이하 + 표준 + 표준이상 + + + - - {/* 골격근 지방 분석 */} - - 골격근 지방 분석 - - - 표준이하 - 표준 - 표준이상 + {/* 부위별 근육 분석 */} + + 부위별 근육 분석 + + {segmentalMuscleItems.map((item, index) => ( + + {item.label} + {item.value} + + + ))} - - - - - {/* 비만 분석 */} - - 비만 분석 - - - 표준이하 - 표준 - 표준이상 + {/* 부위별 체지방 분석 */} + + 부위별 체지방 분석 + + {[ + { + label: "오른팔 체지방", + keys: ["rightArmFat"], + segmentalKey: "rightArm", + }, + { + label: "왼팔 체지방", + keys: ["leftArmFat"], + segmentalKey: "leftArm", + }, + { + label: "몸통 체지방", + keys: ["trunkFat"], + segmentalKey: "trunk", + }, + { + label: "오른다리 체지방", + keys: ["rightLegFat"], + segmentalKey: "rightLeg", + }, + { + label: "왼다리 체지방", + keys: ["leftLegFat"], + segmentalKey: "leftLeg", + }, + ].map((item, index, array) => { + // 수기 입력 필드명 매핑 (rArmFat -> rightArmFat 등) + const manualFieldMap: { [key: string]: string } = { + rightArmFat: "rArmFat", + leftArmFat: "lArmFat", + trunkFat: "trunkFat", + rightLegFat: "rLegFat", + leftLegFat: "lLegFat", + }; + const manualFieldKey = manualFieldMap[item.keys[0]]; + + // 디버깅: 각 필드별 값 확인 (모든 항목에 대해) + if (__DEV__) { + console.log( + `[INBODY] 부위별 체지방 값 추출 [${item.label}]:`, + { + label: item.label, + segmentalKey: item.segmentalKey, + directValue: inBodyData[item.keys[0]], + manualFieldValue: manualFieldKey + ? inBodyData[manualFieldKey] + : null, + segmentalFatAnalysisValue: + inBodyData.segmentalFatAnalysis?.[ + item.segmentalKey + ], + segmentalFatAnalysis: inBodyData.segmentalFatAnalysis, + hasSegmentalFatAnalysis: + !!inBodyData.segmentalFatAnalysis, + allKeys: inBodyData + ? Object.keys(inBodyData).filter( + (k) => + k.includes("Fat") || + k.includes("Arm") || + k.includes("Leg") || + k.includes("segmental") + ) + : [], + inBodyDataKeys: inBodyData + ? Object.keys(inBodyData) + : [], + } + ); + } + + const value = + inBodyData[item.keys[0]] ?? // 1순위: 직접 필드 (rightArmFat 등) + (manualFieldKey ? inBodyData[manualFieldKey] : null) ?? // 1-1순위: 수기 입력 필드명 (rArmFat 등) + inBodyData.segmentalFatAnalysis?.[item.segmentalKey] ?? // 2순위: segmentalFatAnalysis 객체 + inBodyData.muscleFatAnalysis?.[item.keys[0]] ?? // 3순위: muscleFatAnalysis 객체 + (manualFieldKey + ? inBodyData.muscleFatAnalysis?.[manualFieldKey] + : null) ?? // 3-1순위: muscleFatAnalysis (수기 입력 필드명) + inBodyData.segmentalBodyFat?.[item.keys[0]] ?? // 4순위: segmentalBodyFat 객체 + inBodyData.segmentalBodyFat?.[item.segmentalKey] ?? // 5순위: segmentalBodyFat (변환된 키) + inBodyData.segmentalFatRatio?.[item.segmentalKey] ?? // 6순위: segmentalFatRatio + null; + + // 디버깅: 최종 값 확인 (모든 항목에 대해) + if (__DEV__) { + console.log( + `[INBODY] 부위별 체지방 최종 값 [${item.label}]:`, + { + label: item.label, + value, + numValue: parseNumericValue(value), + valueType: typeof value, + isNull: value === null, + isUndefined: value === undefined, + } + ); + } + + const numValue = parseNumericValue(value); + return ( + + {item.label} + + {numValue !== undefined + ? `${numValue.toFixed(1)}kg` + : "-"} + + + + ); + })} - - + + ) : ( + + 인바디 데이터가 없습니다. + + 수기로 입력하거나 사진으로 입력해주세요. + + + )} + + ) : ( + + {historyLoading ? ( + + + 데이터 로딩 중... + ) : historyData.length === 0 ? ( + + 분석 데이터가 없습니다 + + 인바디 기록을 추가하면 분석 그래프를 확인할 수 있습니다. + + + ) : ( + <> + {/* 체중 변화 선 그래프 */} + + 체중 변화 + { + const date = new Date(item.measurementDate); + return `${date.getMonth() + 1}/${date.getDate()}`; + }), + datasets: [ + { + data: historyData.map((item) => item.weight), + color: (opacity = 1) => + `rgba(227, 255, 124, ${opacity})`, + strokeWidth: 2, + }, + ], + }} + width={Math.min(Dimensions.get("window").width - 80, 360)} + height={220} + chartConfig={{ + backgroundColor: "#2a2a2a", + backgroundGradientFrom: "#2a2a2a", + backgroundGradientTo: "#2a2a2a", + decimalPlaces: 1, + color: (opacity = 1) => `rgba(227, 255, 124, ${opacity})`, + labelColor: (opacity = 1) => + `rgba(255, 255, 255, ${opacity})`, + style: { + borderRadius: 16, + }, + propsForDots: { + r: "4", + strokeWidth: "2", + stroke: "#E3FF7C", + }, + }} + bezier + style={styles.chart} + /> + - {/* 부위별 근육 분석 */} - - 부위별 근육 분석 - - {segmentalMuscleItems.map((item, index) => ( - - {item.label} - {item.value} - + {/* 골격근량 vs 체중 비교 그래프 (이중 선 그래프) */} + + 골격근량 vs 체중 + { + const date = new Date(item.measurementDate); + return `${date.getMonth() + 1}/${date.getDate()}`; + }), + datasets: [ + { + data: historyData.map((item) => item.weight), + color: (opacity = 1) => + `rgba(227, 255, 124, ${opacity})`, + strokeWidth: 2, + }, + { + data: historyData.map( + (item) => item.skeletalMuscleMass + ), + color: (opacity = 1) => + `rgba(116, 185, 255, ${opacity})`, + strokeWidth: 2, + }, + ], + }} + width={Math.min(Dimensions.get("window").width - 80, 360)} + height={220} + chartConfig={{ + backgroundColor: "#2a2a2a", + backgroundGradientFrom: "#2a2a2a", + backgroundGradientTo: "#2a2a2a", + decimalPlaces: 1, + color: (opacity = 1) => `rgba(227, 255, 124, ${opacity})`, + labelColor: (opacity = 1) => + `rgba(255, 255, 255, ${opacity})`, + style: { + borderRadius: 16, + }, + propsForDots: { + r: "4", + strokeWidth: "2", + stroke: "#E3FF7C", + }, + }} + bezier + style={styles.chart} + /> + + + + 체중 - ))} + + + 골격근량 + + - - {/* 부위별 체지방 분석 */} - - 부위별 체지방 분석 - - {[ - { - label: "오른팔 체지방", - keys: ["rightArmFat"], - segmentalKey: "rightArm", - }, - { - label: "왼팔 체지방", - keys: ["leftArmFat"], - segmentalKey: "leftArm", - }, - { - label: "몸통 체지방", - keys: ["trunkFat"], - segmentalKey: "trunk", - }, - { - label: "오른다리 체지방", - keys: ["rightLegFat"], - segmentalKey: "rightLeg", - }, - { - label: "왼다리 체지방", - keys: ["leftLegFat"], - segmentalKey: "leftLeg", - }, - ].map((item, index, array) => { - // 수기 입력 필드명 매핑 (rArmFat -> rightArmFat 등) - const manualFieldMap: { [key: string]: string } = { - rightArmFat: "rArmFat", - leftArmFat: "lArmFat", - trunkFat: "trunkFat", - rightLegFat: "rLegFat", - leftLegFat: "lLegFat", - }; - const manualFieldKey = manualFieldMap[item.keys[0]]; - - // 디버깅: 각 필드별 값 확인 (모든 항목에 대해) - if (__DEV__) { - console.log( - `[INBODY] 부위별 체지방 값 추출 [${item.label}]:`, + {/* 체지방률 변화 선 그래프 */} + + 체지방률 변화 + { + const date = new Date(item.measurementDate); + return `${date.getMonth() + 1}/${date.getDate()}`; + }), + datasets: [ { - label: item.label, - segmentalKey: item.segmentalKey, - directValue: inBodyData[item.keys[0]], - manualFieldValue: manualFieldKey - ? inBodyData[manualFieldKey] - : null, - segmentalFatAnalysisValue: - inBodyData.segmentalFatAnalysis?.[item.segmentalKey], - segmentalFatAnalysis: inBodyData.segmentalFatAnalysis, - hasSegmentalFatAnalysis: - !!inBodyData.segmentalFatAnalysis, - allKeys: inBodyData - ? Object.keys(inBodyData).filter( - (k) => - k.includes("Fat") || - k.includes("Arm") || - k.includes("Leg") || - k.includes("segmental") - ) - : [], - inBodyDataKeys: inBodyData - ? Object.keys(inBodyData) - : [], - } - ); - } - - const value = - inBodyData[item.keys[0]] ?? // 1순위: 직접 필드 (rightArmFat 등) - (manualFieldKey ? inBodyData[manualFieldKey] : null) ?? // 1-1순위: 수기 입력 필드명 (rArmFat 등) - inBodyData.segmentalFatAnalysis?.[item.segmentalKey] ?? // 2순위: segmentalFatAnalysis 객체 - inBodyData.muscleFatAnalysis?.[item.keys[0]] ?? // 3순위: muscleFatAnalysis 객체 - (manualFieldKey - ? inBodyData.muscleFatAnalysis?.[manualFieldKey] - : null) ?? // 3-1순위: muscleFatAnalysis (수기 입력 필드명) - inBodyData.segmentalBodyFat?.[item.keys[0]] ?? // 4순위: segmentalBodyFat 객체 - inBodyData.segmentalBodyFat?.[item.segmentalKey] ?? // 5순위: segmentalBodyFat (변환된 키) - inBodyData.segmentalFatRatio?.[item.segmentalKey] ?? // 6순위: segmentalFatRatio - null; - - // 디버깅: 최종 값 확인 (모든 항목에 대해) - if (__DEV__) { - console.log( - `[INBODY] 부위별 체지방 최종 값 [${item.label}]:`, + data: historyData.map((item) => item.bodyFatPercentage), + color: (opacity = 1) => + `rgba(227, 255, 124, ${opacity})`, + strokeWidth: 2, + }, + ], + }} + width={Math.min(Dimensions.get("window").width - 80, 360)} + height={220} + chartConfig={{ + backgroundColor: "#2a2a2a", + backgroundGradientFrom: "#2a2a2a", + backgroundGradientTo: "#2a2a2a", + decimalPlaces: 1, + color: (opacity = 1) => `rgba(227, 255, 124, ${opacity})`, + labelColor: (opacity = 1) => + `rgba(255, 255, 255, ${opacity})`, + style: { + borderRadius: 16, + }, + propsForDots: { + r: "4", + strokeWidth: "2", + stroke: "#E3FF7C", + }, + }} + bezier + style={styles.chart} + /> + + + {/* BMI 그래프와 점수를 함께 나타내는 막대 그래프 */} + + BMI & 건강 점수 + { + const date = new Date(item.measurementDate); + return `${date.getMonth() + 1}/${date.getDate()}`; + }), + datasets: [ { - label: item.label, - value, - numValue: parseNumericValue(value), - valueType: typeof value, - isNull: value === null, - isUndefined: value === undefined, - } + data: historyData.map((item) => item.bmi), + }, + ], + }} + width={Math.min(Dimensions.get("window").width - 80, 360)} + height={220} + yAxisLabel="" + yAxisSuffix="" + chartConfig={{ + backgroundColor: "#2a2a2a", + backgroundGradientFrom: "#2a2a2a", + backgroundGradientTo: "#2a2a2a", + decimalPlaces: 1, + color: (opacity = 1) => `rgba(227, 255, 124, ${opacity})`, + labelColor: (opacity = 1) => + `rgba(255, 255, 255, ${opacity})`, + style: { + borderRadius: 16, + }, + }} + style={styles.chart} + showValuesOnTopOfBars + /> + {/* 점수 표시 */} + + {historyData.map((item, index) => { + const date = new Date(item.measurementDate); + return ( + + + {`${date.getMonth() + 1}/${date.getDate()}`} + + {item.score}점 + ); - } - - const numValue = parseNumericValue(value); - return ( - - {item.label} - - {numValue !== undefined - ? `${numValue.toFixed(1)}kg` - : "-"} - - - - ); - })} + })} + - - - ) : ( - - 인바디 데이터가 없습니다. - - 수기로 입력하거나 사진으로 입력해주세요. - - - )} - + + )} + + )} {/* 캘린더 모달 */} {selectedDate && ( @@ -1543,6 +2076,130 @@ const styles = StyleSheet.create({ dateNavigatorWrapper: { marginTop: 8, }, + tabContainer: { + flexDirection: "row", + paddingHorizontal: 20, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#333333", + gap: 8, + }, + tabButton: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + backgroundColor: "#2a2a2a", + alignItems: "center", + justifyContent: "center", + }, + tabButtonActive: { + backgroundColor: "#E3FF7C", + }, + tabButtonText: { + fontSize: 14, + fontWeight: "500", + color: "#aaaaaa", + }, + tabButtonTextActive: { + color: "#1c1c1c", + fontWeight: "600", + }, + aiCommentContainer: { + marginBottom: 20, + marginTop: 8, + }, + aiCommentBox: { + backgroundColor: "#2a2a2a", + borderRadius: 12, + padding: 16, + borderLeftWidth: 3, + borderLeftColor: "#E3FF7C", + }, + aiCommentHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 12, + gap: 8, + }, + aiCommentTitle: { + fontSize: 14, + fontWeight: "600", + color: "#E3FF7C", + }, + aiCommentText: { + fontSize: 14, + lineHeight: 20, + color: "#ffffff", + }, + aiCommentLoading: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 16, + gap: 8, + }, + aiCommentLoadingText: { + fontSize: 14, + color: "#aaaaaa", + }, + chartSection: { + backgroundColor: "#2a2a2a", + borderRadius: 16, + padding: 16, + marginBottom: 20, + }, + chartTitle: { + fontSize: 16, + fontWeight: "600", + color: "#ffffff", + marginBottom: 16, + }, + chart: { + borderRadius: 16, + marginVertical: 8, + }, + legendContainer: { + flexDirection: "row", + justifyContent: "center", + gap: 24, + marginTop: 12, + }, + legendItem: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + legendDot: { + width: 12, + height: 12, + borderRadius: 6, + }, + legendText: { + fontSize: 14, + color: "#ffffff", + }, + scoreList: { + marginTop: 16, + paddingTop: 16, + borderTopWidth: 1, + borderTopColor: "#333333", + }, + scoreItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingVertical: 8, + }, + scoreDate: { + fontSize: 14, + color: "#aaaaaa", + }, + scoreValue: { + fontSize: 16, + fontWeight: "600", + color: "#E3FF7C", + }, }); export default InBodyScreen; diff --git a/src/screens/main/HomeScreen.tsx b/src/screens/main/HomeScreen.tsx index fb6c229..68fd0c5 100644 --- a/src/screens/main/HomeScreen.tsx +++ b/src/screens/main/HomeScreen.tsx @@ -21,6 +21,7 @@ import type { WeeklyCoachReport } from "../../services/homeAPI"; import { getTodayWorkoutTime, fetchSavedWorkouts, + getWorkoutCalories, } from "../../utils/exerciseApi"; import { getLatestInBody } from "../../utils/inbodyApi"; import AsyncStorage from "@react-native-async-storage/async-storage"; @@ -36,6 +37,7 @@ const HomeScreen = ({ navigation }: any) => { const [homeData, setHomeData] = useState(null); const [todayWorkoutSeconds, setTodayWorkoutSeconds] = useState(0); const [todayExerciseCount, setTodayExerciseCount] = useState(0); + const [todayCalories, setTodayCalories] = useState(0); const [inBodyData, setInBodyData] = useState(null); const isLoadingRef = useRef(false); @@ -291,6 +293,7 @@ const HomeScreen = ({ navigation }: any) => { console.warn("[HOME][DEBUG] userId를 찾을 수 없음, 0으로 설정"); setTodayWorkoutSeconds(0); setTodayExerciseCount(0); + setTodayCalories(0); return; } @@ -306,6 +309,17 @@ const HomeScreen = ({ navigation }: any) => { const today = new Date(); const dateString = formatDateToString(today); + // 3) 오늘의 소모 칼로리 조회 + try { + const caloriesResponse = await getWorkoutCalories(userId, dateString); + const totalCalories = Number(caloriesResponse?.totalCalories) || 0; + console.log("[HOME][소모칼로리] API 응답:", caloriesResponse); + setTodayCalories(totalCalories >= 0 ? totalCalories : 0); + } catch (caloriesError) { + console.error("[HOME][소모칼로리] 소모 칼로리 조회 실패:", caloriesError); + setTodayCalories(0); + } + console.log("[HOME][운동시간] 날짜 확인:", { today: today.toISOString(), dateString, @@ -320,6 +334,7 @@ const HomeScreen = ({ navigation }: any) => { console.error("[HOME][운동시간] 잘못된 날짜 형식:", dateString); setTodayWorkoutSeconds(0); setTodayExerciseCount(0); + setTodayCalories(0); return; } const savedWorkouts = await fetchSavedWorkouts(userId, dateString); @@ -333,6 +348,7 @@ const HomeScreen = ({ navigation }: any) => { } ); setTodayExerciseCount(0); + setTodayCalories(0); return; } @@ -399,6 +415,7 @@ const HomeScreen = ({ navigation }: any) => { } setTodayWorkoutSeconds(0); setTodayExerciseCount(0); + setTodayCalories(0); } }; @@ -799,7 +816,9 @@ const HomeScreen = ({ navigation }: any) => { 소모 칼로리 - - + + {todayCalories.toLocaleString()} + kcal @@ -816,57 +835,80 @@ const HomeScreen = ({ navigation }: any) => { - {/* 체중/골격근량/체지방량 카드 */} - - - 체중 - - {inBodyData?.weight - ? `${inBodyData.weight.toFixed(1)}kg` - : inBodyData?.bodyComposition?.weight - ? `${parseFloat( - String(inBodyData.bodyComposition.weight).replace( - /[^\d.]/g, - "" - ) - ).toFixed(1)}kg` - : inBodyData?.muscleFatAnalysis?.weight - ? `${inBodyData.muscleFatAnalysis.weight.toFixed(1)}kg` - : "-"} - - + {/* 체중/골격근량/체지방량 카드 - 인바디 기록이 있을 때만 표시 */} + {(() => { + // 인바디 기록이 있는지 확인 (체중, 골격근량, 체지방량 중 하나라도 값이 있으면 표시) + const hasWeight = + inBodyData?.weight || + inBodyData?.bodyComposition?.weight || + inBodyData?.muscleFatAnalysis?.weight; + const hasSkeletalMuscleMass = + inBodyData?.skeletalMuscleMass || + inBodyData?.muscleFatAnalysis?.skeletalMuscleMass; + const hasBodyFatMass = + inBodyData?.bodyFatMass || + inBodyData?.muscleFatAnalysis?.bodyFatMass || + inBodyData?.bodyComposition?.bodyFatMass; + + const hasInBodyData = hasWeight || hasSkeletalMuscleMass || hasBodyFatMass; + + if (!hasInBodyData) { + return null; + } + + return ( + + + 체중 + + {inBodyData?.weight + ? `${inBodyData.weight.toFixed(1)}kg` + : inBodyData?.bodyComposition?.weight + ? `${parseFloat( + String(inBodyData.bodyComposition.weight).replace( + /[^\d.]/g, + "" + ) + ).toFixed(1)}kg` + : inBodyData?.muscleFatAnalysis?.weight + ? `${inBodyData.muscleFatAnalysis.weight.toFixed(1)}kg` + : "-"} + + - - 골격근량 - - {inBodyData?.skeletalMuscleMass - ? `${inBodyData.skeletalMuscleMass.toFixed(1)}kg` - : inBodyData?.muscleFatAnalysis?.skeletalMuscleMass - ? `${inBodyData.muscleFatAnalysis.skeletalMuscleMass.toFixed( - 1 - )}kg` - : "-"} - - + + 골격근량 + + {inBodyData?.skeletalMuscleMass + ? `${inBodyData.skeletalMuscleMass.toFixed(1)}kg` + : inBodyData?.muscleFatAnalysis?.skeletalMuscleMass + ? `${inBodyData.muscleFatAnalysis.skeletalMuscleMass.toFixed( + 1 + )}kg` + : "-"} + + - - 체지방량 - - {inBodyData?.bodyFatMass - ? `${inBodyData.bodyFatMass.toFixed(1)}kg` - : inBodyData?.muscleFatAnalysis?.bodyFatMass - ? `${inBodyData.muscleFatAnalysis.bodyFatMass.toFixed(1)}kg` - : inBodyData?.bodyComposition?.bodyFatMass - ? `${parseFloat( - String(inBodyData.bodyComposition.bodyFatMass).replace( - /[^\d.]/g, - "" - ) - ).toFixed(1)}kg` - : "-"} - - - + + 체지방량 + + {inBodyData?.bodyFatMass + ? `${inBodyData.bodyFatMass.toFixed(1)}kg` + : inBodyData?.muscleFatAnalysis?.bodyFatMass + ? `${inBodyData.muscleFatAnalysis.bodyFatMass.toFixed(1)}kg` + : inBodyData?.bodyComposition?.bodyFatMass + ? `${parseFloat( + String(inBodyData.bodyComposition.bodyFatMass).replace( + /[^\d.]/g, + "" + ) + ).toFixed(1)}kg` + : "-"} + + + + ); + })()} {/* 식단 추천 섹션 */} diff --git a/src/utils/exerciseApi.ts b/src/utils/exerciseApi.ts index 00e530c..caad923 100644 --- a/src/utils/exerciseApi.ts +++ b/src/utils/exerciseApi.ts @@ -151,6 +151,7 @@ export interface WorkoutSession { sets: WorkoutSet[]; userId: number | string; // 필수 필드 exerciseId?: string; // externalId + seconds?: number; // 각 운동에 소요된 시간 (초 단위) imageUrl?: string; exerciseImageUrl?: string; image?: string; @@ -533,10 +534,19 @@ export const fetchDateProgress = async ( } }; -// 오늘 운동시간 조회 API +/** + * 오늘 운동시간 조회 API + * GET /api/workouts/time/{userId} + * + * 특정 유저의 오늘 총 운동시간(totalExerciseSeconds)을 조회한다. + * 오늘의 운동시간 기본값은 0이다. + * + * @param userId - 조회할 사용자 ID + * @returns { userId: number, totalSeconds: number } - userId: 사용자 ID, totalSeconds: 오늘 하루 누적된 운동시간(초) + */ export interface GetTodayWorkoutTimeResponse { userId: number; - totalSeconds: number; + totalSeconds: number; // 오늘 하루 누적된 운동시간(초), 기본값 0 } export const getTodayWorkoutTime = async ( @@ -561,18 +571,70 @@ export const getTodayWorkoutTime = async ( status: error.response?.status, data: error.response?.data, }); - // 404나 다른 에러 시 기본값 반환 + // 404나 다른 에러 시 기본값 0 반환 (API 스펙에 따라) if (error.response?.status === 404) { return { userId, totalSeconds: 0 }; } } else { console.error("[WORKOUT][TIME] 오늘 운동 시간 조회 예외:", error); } - // 에러 발생 시 기본값 반환 + // 에러 발생 시 기본값 0 반환 (API 스펙에 따라) return { userId, totalSeconds: 0 }; } }; +/** + * 특정 날짜의 소모 칼로리 조회 + * GET /api/workouts/{user_id}/calories/{date} + * + * @param userId - 조회할 사용자 ID + * @param date - 조회할 날짜 (YYYY-MM-DD 형식) + * @returns { date: string, totalCalories: number, sessions: Array<{ sessionId: string, sessionCalories: number }> } + */ +export interface GetWorkoutCaloriesResponse { + date: string; + totalCalories: number; + sessions: Array<{ + sessionId: string; + sessionCalories: number; + }>; +} + +export const getWorkoutCalories = async ( + userId: number, + date: string +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${WORKOUTS_API_URL}/${encodeURIComponent(userId)}/calories/${encodeURIComponent(date)}`; + console.log("[WORKOUT][CALORIES] 소모 칼로리 조회 요청:", url); + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + Accept: "application/json", + }, + }); + console.log("[WORKOUT][CALORIES] 소모 칼로리 조회 응답:", response.data); + return response.data as GetWorkoutCaloriesResponse; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error("[WORKOUT][CALORIES] 소모 칼로리 조회 에러:", { + message: error.message, + status: error.response?.status, + data: error.response?.data, + }); + // 404나 다른 에러 시 기본값 반환 + if (error.response?.status === 404) { + return { date, totalCalories: 0, sessions: [] }; + } + } else { + console.error("[WORKOUT][CALORIES] 소모 칼로리 조회 예외:", error); + } + // 에러 발생 시 기본값 반환 + return { date, totalCalories: 0, sessions: [] }; + } +}; + // 운동 저장 제목 API export interface SaveWorkoutTitleRequest { userId: number; @@ -580,7 +642,6 @@ export interface SaveWorkoutTitleRequest { date: string; // 오늘의 날짜 정보만 넣어야함 (YYYY-MM-DD) intensity?: number[]; // 무거워요(7.5), 선택안함(5.0), 가벼워요(2.5) feedback?: string[]; // 좋아요(like), 선택안함(neutral), 싫어요(dislike) - seconds: number; // 운동 시간 (초 단위) - 필수 필드 } export interface SaveWorkoutTitleResponse { @@ -594,8 +655,7 @@ export const saveWorkoutTitle = async ( userId: number, saveTitle: string, intensity?: number[], - feedback?: string[], - seconds?: number + feedback?: string[] ): Promise => { try { const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); @@ -624,10 +684,6 @@ export const saveWorkoutTitle = async ( ...(intensityFloat && intensityFloat.length > 0 && { intensity: intensityFloat }), ...(feedback && feedback.length > 0 && { feedback }), - // seconds는 필수 필드 (초 단위) - // 백엔드가 0을 허용하지 않을 수 있으므로 최소값 1로 설정 - seconds: - seconds !== undefined && seconds !== null && seconds > 0 ? seconds : 1, }; // 페이로드 검증 로그 @@ -643,8 +699,6 @@ export const saveWorkoutTitle = async ( date: payload.date, intensityLength: payload.intensity?.length, feedbackLength: payload.feedback?.length, - seconds: payload.seconds, - secondsType: typeof payload.seconds, }, }); diff --git a/src/utils/inbodyApi.ts b/src/utils/inbodyApi.ts index 421b255..9ad2c4a 100644 --- a/src/utils/inbodyApi.ts +++ b/src/utils/inbodyApi.ts @@ -474,4 +474,162 @@ export const uploadInBodyImage = async ( } throw error; } +}; + +/** + * 인바디 AI 코멘트 조회 + */ +export interface InBodyCommentRequest { + user_id: string; + period: string; // "2025-01-04 ~ 2025-07-04" + latest: { + date: string; // "2025-07-04" + weight: number; + body_fat_percentage: number; + skeletal_muscle_mass: number; + }; + trend: { + weight: "UP" | "DOWN" | "SAME"; + body_fat_percentage: "UP" | "DOWN" | "SAME"; + skeletal_muscle_mass: "UP" | "DOWN" | "SAME"; + }; +} + +export interface InBodyCommentResponse { + comment: string; +} + +export const getInBodyComment = async ( + request: InBodyCommentRequest +): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + + // GET 요청을 위한 쿼리 파라미터 구성 + const params = new URLSearchParams({ + user_id: request.user_id, + period: request.period, + date: request.latest.date, + weight: request.latest.weight.toString(), + body_fat_percentage: request.latest.body_fat_percentage.toString(), + skeletal_muscle_mass: request.latest.skeletal_muscle_mass.toString(), + weight_trend: request.trend.weight, + body_fat_percentage_trend: request.trend.body_fat_percentage, + skeletal_muscle_mass_trend: request.trend.skeletal_muscle_mass, + }); + + const url = `${INBODY_API_URL}/comment/daily/weight?${params.toString()}`; + + console.log("[INBODY][COMMENT] AI 코멘트 조회 요청:", { + url, + request, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + console.log("[INBODY][COMMENT] AI 코멘트 조회 응답:", { + status: response.status, + comment: response.data?.comment, + }); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + console.error("[INBODY][COMMENT] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + data: errorData, + requestUrl: error.config?.url, + requestData: error.config?.data, + }); + + // 404나 400은 코멘트 없음으로 처리 + if (status === 404 || status === 400) { + console.log("[INBODY][COMMENT] 코멘트 없음 (404/400)"); + return { comment: "" }; + } + + // 500 에러는 서버 내부 오류이므로 빈 코멘트 반환 (사용자에게는 표시 안 함) + if (status === 500) { + console.warn("[INBODY][COMMENT] 서버 내부 오류 (500) - 코멘트 표시 안 함"); + return { comment: "" }; + } + } else { + console.error("[INBODY][COMMENT] 예상치 못한 에러:", error); + } + return { comment: "" }; + } +}; + +/** + * 인바디 히스토리 조회 + */ +export interface InBodyHistoryItem { + id: number; + measurementDate: string; + weight: number; + skeletalMuscleMass: number; + bodyFatMass: number; + bodyFatPercentage: number; + bmi: number; + score: number; +} + +export const getInBodyHistory = async (): Promise => { + try { + const token = await AsyncStorage.getItem(ACCESS_TOKEN_KEY); + const url = `${INBODY_API_URL}/history`; + + console.log("[INBODY][HISTORY] API 요청:", { + url, + hasToken: !!token, + }); + + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${token || ""}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + console.log("[INBODY][HISTORY] API 응답:", { + status: response.status, + dataCount: Array.isArray(response.data) ? response.data.length : 0, + }); + + return Array.isArray(response.data) ? response.data : []; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const errorData = error.response?.data; + + console.error("[INBODY][HISTORY] API 에러:", { + message: error.message, + status, + statusText: error.response?.statusText, + data: errorData, + }); + + // 404나 400은 빈 배열 반환 + if (status === 404 || status === 400) { + console.log("[INBODY][HISTORY] 데이터 없음 (404/400)"); + return []; + } + } else { + console.error("[INBODY][HISTORY] 예상치 못한 에러:", error); + } + return []; + } }; \ No newline at end of file