diff --git a/.gitignore b/.gitignore index 5ac35103..6626db02 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .claude/ CLAUDE.md +# Git Hooks (로컬 전용) +.githooks/ + # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,cocoapods # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,cocoapods diff --git a/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift b/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift index 48cf6dd5..bc79c39a 100644 --- a/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift @@ -2,7 +2,7 @@ // WatchSessionManager.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import Foundation @@ -106,6 +106,26 @@ final class WatchSessionManager: NSObject, ObservableObject { runningState = .idle } + // MARK: - Send Health Data to iPhone + + func sendHealthSummary(_ data: [String: Any]) { + guard WCSession.default.activationState == .activated else { return } + // transferUserInfo guarantees delivery even when phone is unreachable + WCSession.default.transferUserInfo(data) + } + + func sendRealtimeHealth(heartRate: Double, calories: Double) { + guard WCSession.default.isReachable else { return } + let message: [String: Any] = [ + "messageType": "realtimeHealth", + "heartRate": heartRate, + "calories": calories + ] + WCSession.default.sendMessage(message, replyHandler: nil) { error in + print("[WatchSession] Realtime health send error: \(error.localizedDescription)") + } + } + // MARK: - Send control messages to iPhone func sendRunCommand(_ command: String) { diff --git a/Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift b/Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift index 2a54a746..9ceb6b77 100644 --- a/Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift +++ b/Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift @@ -2,7 +2,7 @@ // HeartRateZone.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/09. +// Created by 이명진 on 2026/02/09. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Models/WatchRunningData.swift b/Runnect-iOS/RNWatch Watch App/Models/WatchRunningData.swift index 547f8877..59a244c4 100644 --- a/Runnect-iOS/RNWatch Watch App/Models/WatchRunningData.swift +++ b/Runnect-iOS/RNWatch Watch App/Models/WatchRunningData.swift @@ -2,7 +2,7 @@ // WatchRunningData.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import Foundation diff --git a/Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift b/Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift index fe146c15..9b745a34 100644 --- a/Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift @@ -2,7 +2,7 @@ // HapticManager.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import WatchKit diff --git a/Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift b/Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift index 00bad78f..f6d7c64e 100644 --- a/Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift +++ b/Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift @@ -2,7 +2,7 @@ // RunnectColors.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Utils/WatchTimeFormatter.swift b/Runnect-iOS/RNWatch Watch App/Utils/WatchTimeFormatter.swift index 59dc4d38..3965393b 100644 --- a/Runnect-iOS/RNWatch Watch App/Utils/WatchTimeFormatter.swift +++ b/Runnect-iOS/RNWatch Watch App/Utils/WatchTimeFormatter.swift @@ -2,7 +2,7 @@ // WatchTimeFormatter.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import Foundation diff --git a/Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift b/Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift index 025d636d..969e71b4 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift @@ -2,7 +2,7 @@ // ActiveRunView.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift b/Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift index 02cfe7fc..fa0ea941 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift @@ -2,7 +2,7 @@ // CountdownView.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Views/CourseProgressBar.swift b/Runnect-iOS/RNWatch Watch App/Views/CourseProgressBar.swift index 8e6510a3..660c72ba 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/CourseProgressBar.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/CourseProgressBar.swift @@ -2,7 +2,7 @@ // CourseProgressBar.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/09. +// Created by 이명진 on 2026/02/09. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Views/KilometerAlertView.swift b/Runnect-iOS/RNWatch Watch App/Views/KilometerAlertView.swift index b7d1c82c..41db6260 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/KilometerAlertView.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/KilometerAlertView.swift @@ -2,7 +2,7 @@ // KilometerAlertView.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift b/Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift index 3ca6ea08..1372d1cd 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift @@ -2,7 +2,7 @@ // RunSummaryView.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift b/Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift index af17ae32..aeea0d08 100644 --- a/Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift +++ b/Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift @@ -2,7 +2,7 @@ // RunningIdleView.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import SwiftUI diff --git a/Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift b/Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift index a618aae9..b2aa4c94 100644 --- a/Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift +++ b/Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift @@ -2,7 +2,7 @@ // WatchAppDelegate.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import WatchKit diff --git a/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift index 031ef52c..2484f151 100644 --- a/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift @@ -2,7 +2,7 @@ // WorkoutManager.swift // RNWatch Watch App // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import Foundation @@ -20,11 +20,17 @@ final class WorkoutManager: NSObject, ObservableObject { // Summary values preserved after workout ends @Published var summaryHeartRate: Double = 0 @Published var summaryCalories: Double = 0 + @Published var summaryMaxHeartRate: Double = 0 private let healthStore = HKHealthStore() private var workoutSession: HKWorkoutSession? private var workoutBuilder: HKLiveWorkoutBuilder? + // Heart rate sample tracking for zone distribution + private var heartRateSamples: [(bpm: Double, date: Date)] = [] + private var maxHeartRateValue: Double = 0 + private let estimatedMaxHR: Double = 190 + private override init() { super.init() } @@ -67,6 +73,9 @@ final class WorkoutManager: NSObject, ObservableObject { workoutConfiguration: configuration ) + heartRateSamples.removeAll() + maxHeartRateValue = 0 + let startDate = Date() workoutSession?.startActivity(with: startDate) workoutBuilder?.beginCollection(withStart: startDate) { success, error in @@ -89,6 +98,7 @@ final class WorkoutManager: NSObject, ObservableObject { // Preserve summary values before session ends and triggers reset summaryHeartRate = heartRate summaryCalories = activeCalories + summaryMaxHeartRate = maxHeartRateValue // Use averageQuantity for heart rate if available if let builder = workoutBuilder, @@ -97,8 +107,14 @@ final class WorkoutManager: NSObject, ObservableObject { if let avg = hrStats.averageQuantity()?.doubleValue(for: hrUnit) { summaryHeartRate = avg } + if let max = hrStats.maximumQuantity()?.doubleValue(for: hrUnit) { + summaryMaxHeartRate = max + } } + // Send health summary to iPhone via transferUserInfo + sendHealthSummaryToiPhone() + workoutSession?.end() DispatchQueue.main.async { @@ -106,6 +122,91 @@ final class WorkoutManager: NSObject, ObservableObject { } } + // MARK: - Health Summary + + func generateHealthSummary() -> [String: Any] { + let avgHR = summaryHeartRate + let maxHR = summaryMaxHeartRate + let minHR = heartRateSamples.map(\.bpm).min() ?? 0 + let calories = summaryCalories + let zones = calculateZoneDistribution() + + var zoneList: [[String: Any]] = [] + for zone in zones { + zoneList.append([ + "zone": zone.zone.rawValue, + "zoneName": zone.zone.name, + "durationSeconds": zone.durationSeconds, + "percentage": zone.percentage + ]) + } + + // 개별 심박수 샘플을 서버 전송 형식으로 변환 + var sampleList: [[String: Any]] = [] + if let startDate = heartRateSamples.first?.date { + for sample in heartRateSamples { + let elapsedSeconds = Int(sample.date.timeIntervalSince(startDate)) + let zone = HeartRateZone.zone(for: sample.bpm, maxHeartRate: estimatedMaxHR) + sampleList.append([ + "heartRate": round(sample.bpm * 10) / 10, + "elapsedSeconds": elapsedSeconds, + "zone": zone.rawValue + ]) + } + } + + return [ + "messageType": "healthSummary", + "avgHeartRate": round(avgHR * 10) / 10, + "maxHeartRate": round(maxHR * 10) / 10, + "minHeartRate": round(minHR * 10) / 10, + "totalCalories": round(calories * 10) / 10, + "heartRateZones": zoneList, + "heartRateSamples": sampleList, + "timestamp": Date().timeIntervalSince1970 + ] + } + + private func sendHealthSummaryToiPhone() { + let summary = generateHealthSummary() + WatchSessionManager.shared.sendHealthSummary(summary) + } + + private func calculateZoneDistribution() -> [(zone: HeartRateZone, durationSeconds: Int, percentage: Double)] { + guard heartRateSamples.count >= 2 else { return [] } + + var zoneDurations: [HeartRateZone: TimeInterval] = [:] + for zone in HeartRateZone.allCases { + zoneDurations[zone] = 0 + } + + for i in 0..<(heartRateSamples.count - 1) { + let sample = heartRateSamples[i] + let nextSample = heartRateSamples[i + 1] + let duration = nextSample.date.timeIntervalSince(sample.date) + let zone = HeartRateZone.zone(for: sample.bpm, maxHeartRate: estimatedMaxHR) + zoneDurations[zone, default: 0] += duration + } + + if let lastSample = heartRateSamples.last { + let zone = HeartRateZone.zone(for: lastSample.bpm, maxHeartRate: estimatedMaxHR) + zoneDurations[zone, default: 0] += 5 + } + + let totalDuration = zoneDurations.values.reduce(0, +) + guard totalDuration > 0 else { return [] } + + return HeartRateZone.allCases.compactMap { zone in + let duration = zoneDurations[zone] ?? 0 + guard duration > 0 else { return nil } + return ( + zone: zone, + durationSeconds: Int(duration), + percentage: round((duration / totalDuration) * 1000) / 10 + ) + } + } + // MARK: - Pause / Resume func pauseWorkout() { @@ -125,7 +226,17 @@ final class WorkoutManager: NSObject, ObservableObject { let heartRateUnit = HKUnit.count().unitDivided(by: .minute()) if let value = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) { self.heartRate = value + self.heartRateSamples.append((bpm: value, date: Date())) + if value > self.maxHeartRateValue { + self.maxHeartRateValue = value + } self.updateHeartRateZone(value) + + // Send realtime health data to iPhone + WatchSessionManager.shared.sendRealtimeHealth( + heartRate: value, + calories: self.activeCalories + ) } case HKQuantityType(.activeEnergyBurned): @@ -158,8 +269,11 @@ final class WorkoutManager: NSObject, ObservableObject { activeCalories = 0 summaryHeartRate = 0 summaryCalories = 0 + summaryMaxHeartRate = 0 isWorkoutActive = false currentZone = .zone1 + heartRateSamples.removeAll() + maxHeartRateValue = 0 workoutSession = nil workoutBuilder = nil } diff --git a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj index 24ee32de..0dea3826 100644 --- a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj +++ b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj @@ -109,6 +109,10 @@ CE4545D5295D7AF5003201E1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE4545D3295D7AF5003201E1 /* LaunchScreen.storyboard */; }; CE4942AD296FCD2300736701 /* UploadedCourseDetailResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4942AC296FCD2300736701 /* UploadedCourseDetailResponseDto.swift */; }; CE55BC11296D4EA600E8CD69 /* RunningRecordRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */; }; + F1A2B3C4D5E6F7A81234AB02 /* RecordResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */; }; + F1A2B3C4D5E6F7A81234AB04 /* HealthDataSaveRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */; }; + F1A2B3C4D5E6F7A81234AB06 /* HealthDataResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */; }; + F1A2B3C4D5E6F7A81234AB08 /* HealthSummaryResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */; }; CE5645162961B72E000A2856 /* ImageLiterals.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5645152961B72E000A2856 /* ImageLiterals.swift */; }; CE58759E29601476005D967E /* LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE58759D29601476005D967E /* LoadingIndicator.swift */; }; CE5875A029601500005D967E /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE58759F29601500005D967E /* Toast.swift */; }; @@ -150,6 +154,7 @@ CE6B63D829673450003F900F /* ListEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6B63D729673450003F900F /* ListEmptyView.swift */; }; CE9291252965C9FB0010959C /* CourseDetailInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9291242965C9FB0010959C /* CourseDetailInfoView.swift */; }; CE9291272965D0ED0010959C /* StatsInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9291262965D0ED0010959C /* StatsInfoView.swift */; }; + D1A2B3C4D5E6F7081234ABCE /* HeartRateZoneBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3C4D5E6F7081234ABCD /* HeartRateZoneBarView.swift */; }; CE9291292965E01D0010959C /* RNTimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9291282965E01D0010959C /* RNTimeFormatter.swift */; }; CEB0BCBC29D123350048CCD5 /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB0BCBB29D123350048CCD5 /* GuideView.swift */; }; CEB8416E2962C45300BF8080 /* LocationSearchResultTVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB8416D2962C45300BF8080 /* LocationSearchResultTVC.swift */; }; @@ -298,6 +303,10 @@ CE4545D6295D7AF5003201E1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CE4942AC296FCD2300736701 /* UploadedCourseDetailResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedCourseDetailResponseDto.swift; sourceTree = ""; }; CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningRecordRequestDto.swift; sourceTree = ""; }; + F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordResponseDto.swift; sourceTree = ""; }; + F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDataSaveRequestDto.swift; sourceTree = ""; }; + F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDataResponseDto.swift; sourceTree = ""; }; + F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSummaryResponseDto.swift; sourceTree = ""; }; CE5645152961B72E000A2856 /* ImageLiterals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLiterals.swift; sourceTree = ""; }; CE58759D29601476005D967E /* LoadingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingIndicator.swift; sourceTree = ""; }; CE58759F29601500005D967E /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; @@ -342,6 +351,7 @@ CE6B63D729673450003F900F /* ListEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListEmptyView.swift; sourceTree = ""; }; CE9291242965C9FB0010959C /* CourseDetailInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailInfoView.swift; sourceTree = ""; }; CE9291262965D0ED0010959C /* StatsInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsInfoView.swift; sourceTree = ""; }; + D1A2B3C4D5E6F7081234ABCD /* HeartRateZoneBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartRateZoneBarView.swift; sourceTree = ""; }; CE9291282965E01D0010959C /* RNTimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNTimeFormatter.swift; sourceTree = ""; }; CEB0BCBB29D123350048CCD5 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = ""; }; CEB8416D2962C45300BF8080 /* LocationSearchResultTVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSearchResultTVC.swift; sourceTree = ""; }; @@ -900,6 +910,7 @@ isa = PBXGroup; children = ( CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */, + F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */, ); path = RequestDto; sourceTree = ""; @@ -908,6 +919,9 @@ isa = PBXGroup; children = ( CEF3CD99296DB305002723A1 /* CourseDetailResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */, ); path = ResponseDto; sourceTree = ""; @@ -1111,6 +1125,7 @@ CE0D9FD229648DA300CEB5CD /* CustomAlertVC.swift */, CE9291242965C9FB0010959C /* CourseDetailInfoView.swift */, CE9291262965D0ED0010959C /* StatsInfoView.swift */, + D1A2B3C4D5E6F7081234ABCD /* HeartRateZoneBarView.swift */, CE6B63D729673450003F900F /* ListEmptyView.swift */, CEB0BCBB29D123350048CCD5 /* GuideView.swift */, A3C2CAD629E53B2900EC525B /* RNAlertVC.swift */, @@ -1423,9 +1438,14 @@ files = ( 715D36E82B2CC64000CAA9D6 /* MyUploadedCourseResponseDto.swift in Sources */, CE55BC11296D4EA600E8CD69 /* RunningRecordRequestDto.swift in Sources */, + F1A2B3C4D5E6F7A81234AB02 /* RecordResponseDto.swift in Sources */, + F1A2B3C4D5E6F7A81234AB04 /* HealthDataSaveRequestDto.swift in Sources */, + F1A2B3C4D5E6F7A81234AB06 /* HealthDataResponseDto.swift in Sources */, + F1A2B3C4D5E6F7A81234AB08 /* HealthSummaryResponseDto.swift in Sources */, A3BC2F2F2962C40A00198261 /* UploadedCourseInfoVC.swift in Sources */, CE6655EA295D88B200C64E12 /* UITabBar+.swift in Sources */, CE9291272965D0ED0010959C /* StatsInfoView.swift in Sources */, + D1A2B3C4D5E6F7081234ABCE /* HeartRateZoneBarView.swift in Sources */, CEC2A68729629B9B00160BF7 /* NickNameSetUpVC.swift in Sources */, DA97A033296E65D80086760E /* CourseUploadingRequestDto.swift in Sources */, 711E18212B38516D00C651CD /* GAManager.swift in Sources */, diff --git a/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift b/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift new file mode 100644 index 00000000..0f356c85 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift @@ -0,0 +1,171 @@ +// +// HeartRateZoneBarView.swift +// Runnect-iOS +// +// Created by 이명진 on 2026/02/22. +// + +import UIKit + +import SnapKit +import Then + +final class HeartRateZoneBarView: UIView { + + // MARK: - Properties + + struct ZoneData { + let zone: Int + let name: String + let percentage: Double + } + + private static let zoneColors: [Int: UIColor] = [ + 1: .m6, + 2: .m5, + 3: .m2, + 4: .m1, + 5: UIColor(hex: "#3A1FCF") + ] + + private static let zoneNames: [Int: String] = [ + 1: "워밍업", + 2: "지방 연소", + 3: "유산소", + 4: "고강도", + 5: "최대" + ] + + // MARK: - UI Components + + private let barContainerView = UIView().then { + $0.layer.cornerRadius = 6 + $0.clipsToBounds = true + } + + private let legendStackView = UIStackView().then { + $0.spacing = 12 + $0.alignment = .center + } + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Methods + +extension HeartRateZoneBarView { + func configure(with zones: [[String: Any]]) { + let zoneDataList = parseZones(zones) + guard zoneDataList.count >= 2 else { + self.isHidden = true + return + } + self.isHidden = false + layoutBarSegments(zoneDataList) + layoutLegend(zoneDataList) + } + + private func parseZones(_ zones: [[String: Any]]) -> [ZoneData] { + var result: [ZoneData] = [] + for zoneDict in zones { + guard let zone = zoneDict["zone"] as? Int, + let percentage = zoneDict["percentage"] as? Double, + percentage > 0 else { continue } + let name = Self.zoneNames[zone] ?? "Zone \(zone)" + result.append(ZoneData(zone: zone, name: name, percentage: percentage)) + } + return result.sorted { $0.zone < $1.zone } + } + + private func layoutBarSegments(_ zones: [ZoneData]) { + barContainerView.subviews.forEach { $0.removeFromSuperview() } + + var previousView: UIView? + let totalPercentage = zones.reduce(0) { $0 + $1.percentage } + guard totalPercentage > 0 else { return } + + for zone in zones { + let segmentView = UIView() + segmentView.backgroundColor = Self.zoneColors[zone.zone] ?? .g4 + barContainerView.addSubview(segmentView) + + let ratio = zone.percentage / totalPercentage + + segmentView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + if let prev = previousView { + $0.leading.equalTo(prev.snp.trailing) + } else { + $0.leading.equalToSuperview() + } + $0.width.equalToSuperview().multipliedBy(ratio) + } + previousView = segmentView + } + } + + private func layoutLegend(_ zones: [ZoneData]) { + legendStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for zone in zones { + let itemView = makeLegendItem( + color: Self.zoneColors[zone.zone] ?? .g4, + text: "\(zone.name) \(Int(zone.percentage))%" + ) + legendStackView.addArrangedSubview(itemView) + } + } + + private func makeLegendItem(color: UIColor, text: String) -> UIView { + let colorDot = UIView().then { + $0.backgroundColor = color + $0.layer.cornerRadius = 4 + } + + let label = UILabel().then { + $0.text = text + $0.font = .b8 + $0.textColor = .g2 + } + + let stack = UIStackView(arrangedSubviews: [colorDot, label]).then { + $0.spacing = 4 + $0.alignment = .center + } + + colorDot.snp.makeConstraints { + $0.width.height.equalTo(8) + } + + return stack + } +} + +// MARK: - UI & Layout + +extension HeartRateZoneBarView { + private func setLayout() { + addSubviews(barContainerView, legendStackView) + + barContainerView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(12) + } + + legendStackView.snp.makeConstraints { + $0.top.equalTo(barContainerView.snp.bottom).offset(8) + $0.leading.equalToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + $0.bottom.equalToSuperview() + } + } +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/ActivityRecordInfoDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/ActivityRecordInfoDto.swift index 1dfd6fda..4dc25ff6 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/ActivityRecordInfoDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/ActivityRecordInfoDto.swift @@ -24,6 +24,14 @@ struct ActivityRecord: Codable { let distance: Double let time, pace: String let departure: ActivityRecordDeparture + let healthData: ActivityRecordHealthData? +} + +// MARK: - ActivityRecordHealthData + +struct ActivityRecordHealthData: Codable { + let avgHeartRate: Double? + let calories: Double? } // MARK: - Departure diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift new file mode 100644 index 00000000..8c2e5a16 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift @@ -0,0 +1,32 @@ +// +// HealthDataSaveRequestDto.swift +// Runnect-iOS +// +// Created by 이명진 on 2026/02/22. +// + +import Foundation + +// MARK: - HealthDataSaveRequestDto + +struct HealthDataSaveRequestDto: Codable { + let avgHeartRate: Double + let maxHeartRate: Double + let minHeartRate: Double? + let calories: Double + let zone1Seconds: Int + let zone2Seconds: Int + let zone3Seconds: Int + let zone4Seconds: Int + let zone5Seconds: Int + let maxHeartRateConfig: Double? + let heartRateSamples: [HeartRateSampleDto]? +} + +// MARK: - HeartRateSampleDto + +struct HeartRateSampleDto: Codable { + let heartRate: Double + let elapsedSeconds: Int + let zone: Int +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift new file mode 100644 index 00000000..1bd575c3 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift @@ -0,0 +1,38 @@ +// +// HealthDataResponseDto.swift +// Runnect-iOS +// +// Created by 이명진 on 2026/02/22. +// + +import Foundation + +// MARK: - HealthDataResponseDto + +struct HealthDataResponseDto: Codable { + let healthData: HealthDataDetail? +} + +// MARK: - HealthDataDetail + +struct HealthDataDetail: Codable { + let id: Int + let recordId: Int + let avgHeartRate: Double + let maxHeartRate: Double + let minHeartRate: Double? + let calories: Double + let zones: HealthZonesDto + let maxHeartRateConfig: Double? + let heartRateSamples: [HeartRateSampleDto]? +} + +// MARK: - HealthZonesDto + +struct HealthZonesDto: Codable { + let zone1Seconds: Int + let zone2Seconds: Int + let zone3Seconds: Int + let zone4Seconds: Int + let zone5Seconds: Int +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift new file mode 100644 index 00000000..4d003353 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift @@ -0,0 +1,25 @@ +// +// HealthSummaryResponseDto.swift +// Runnect-iOS +// +// Created by 이명진 on 2026/02/22. +// + +import Foundation + +// MARK: - HealthSummaryResponseDto + +struct HealthSummaryResponseDto: Codable { + let summary: HealthSummaryDetail +} + +// MARK: - HealthSummaryDetail + +struct HealthSummaryDetail: Codable { + let totalRecords: Int + let recordsWithHealth: Int + let avgHeartRate: Double? + let avgCalories: Double? + let totalCalories: Double? + let zoneDistribution: HealthZonesDto? +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift new file mode 100644 index 00000000..20ad359a --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift @@ -0,0 +1,19 @@ +// +// RecordResponseDto.swift +// Runnect-iOS +// +// Created by 이명진 on 2026/02/22. +// + +import Foundation + +// MARK: - RecordResponseDto + +struct RecordResponseDto: Codable { + let record: RecordDetail + + struct RecordDetail: Codable { + let id: Int + let createdAt: String + } +} diff --git a/Runnect-iOS/Runnect-iOS/Network/Model/RunningModel/RunningModel.swift b/Runnect-iOS/Runnect-iOS/Network/Model/RunningModel/RunningModel.swift index b9aa6ce0..2bc3b135 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Model/RunningModel/RunningModel.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Model/RunningModel/RunningModel.swift @@ -19,6 +19,9 @@ struct RunningModel { var totalTime: Int? var region: String? var city: String? + + // Watch 건강 데이터 (Watch 미연결 시 nil) + var healthSummary: WatchHealthSummary? /// HH:MM:SS 형식으로 반환 func getFormattedTotalTime() -> String? { diff --git a/Runnect-iOS/Runnect-iOS/Network/Router/RecordRouter.swift b/Runnect-iOS/Runnect-iOS/Network/Router/RecordRouter.swift index 683fd297..d0752f84 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Router/RecordRouter.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Router/RecordRouter.swift @@ -14,6 +14,10 @@ enum RecordRouter { case getActivityRecordInfo case deleteRecord(recordIdList: [Int]) case updateRecordTitle(recordId: Int, recordTitle: String) + case saveHealthData(recordId: Int, param: HealthDataSaveRequestDto) + case getHealthData(recordId: Int) + case deleteHealthData(recordId: Int) + case getHealthSummary(startDate: String, endDate: String) } extension RecordRouter: TargetType { @@ -33,19 +37,27 @@ extension RecordRouter: TargetType { return "/record/user" case .updateRecordTitle(recordId: let recordId, _): return "/record/\(recordId)" + case .saveHealthData(let recordId, _), + .getHealthData(let recordId), + .deleteHealthData(let recordId): + return "/record/\(recordId)/health" + case .getHealthSummary: + return "/health/summary" } } var method: Moya.Method { switch self { - case .recordRunning: + case .recordRunning, .saveHealthData: return .post - case .getActivityRecordInfo: + case .getActivityRecordInfo, .getHealthData, .getHealthSummary: return .get case .deleteRecord: return .put case .updateRecordTitle: return .patch + case .deleteHealthData: + return .delete } } @@ -65,6 +77,19 @@ extension RecordRouter: TargetType { do { return .requestParameters(parameters: ["title": recordTitle], encoding: JSONEncoding.default) } + case .saveHealthData(_, let param): + do { + return .requestParameters(parameters: try param.asParameter(), encoding: JSONEncoding.default) + } catch { + fatalError(error.localizedDescription) + } + case .getHealthData, .deleteHealthData: + return .requestPlain + case .getHealthSummary(let startDate, let endDate): + return .requestParameters( + parameters: ["startDate": startDate, "endDate": endDate], + encoding: URLEncoding.queryString + ) } } diff --git a/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift index f02377b5..49e00212 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift @@ -2,7 +2,7 @@ // WatchSessionService.swift // Runnect-iOS // -// Created by Runnect on 2026/02/08. +// Created by 이명진 on 2026/02/08. // import Foundation @@ -14,6 +14,12 @@ final class WatchSessionService: NSObject, ObservableObject { @Published var isWatchReachable = false + // MARK: - Health Data (received from Watch) + + @Published var realtimeHeartRate: Double = 0 + @Published var realtimeCalories: Double = 0 + @Published private(set) var healthSummary: WatchHealthSummary? + private var sendTimer: AnyCancellable? private override init() { @@ -67,6 +73,14 @@ final class WatchSessionService: NSObject, ObservableObject { sendIfReachable(["messageType": "runReset"]) } + // MARK: - Health Data Management + + func clearHealthData() { + realtimeHeartRate = 0 + realtimeCalories = 0 + healthSummary = nil + } + private func sendIfReachable(_ message: [String: Any], retryCount: Int = 0) { guard WCSession.default.activationState == .activated, WCSession.default.isReachable else { @@ -111,6 +125,19 @@ extension WatchSessionService: WCSessionDelegate { } func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handleIncomingMessage(message) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + handleIncomingMessage(message) + replyHandler(["status": "received"]) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + handleIncomingMessage(userInfo) + } + + private func handleIncomingMessage(_ message: [String: Any]) { guard let type = message["messageType"] as? String else { return } switch type { @@ -118,16 +145,39 @@ extension WatchSessionService: WCSessionDelegate { if let command = message["command"] as? String { handleWatchCommand(command) } + + case "realtimeHealth": + DispatchQueue.main.async { + if let heartRate = message["heartRate"] as? Double { + self.realtimeHeartRate = heartRate + } + if let calories = message["calories"] as? Double { + self.realtimeCalories = calories + } + NotificationCenter.default.post( + name: .watchRealtimeHealthReceived, + object: nil, + userInfo: message + ) + } + + case "healthSummary": + if let summary = WatchHealthSummary.fromDictionary(message) { + DispatchQueue.main.async { + self.healthSummary = summary + NotificationCenter.default.post( + name: .watchHealthSummaryReceived, + object: nil, + userInfo: message + ) + } + } + default: break } } - func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - self.session(session, didReceiveMessage: message) - replyHandler(["status": "received"]) - } - private func handleWatchCommand(_ command: String) { DispatchQueue.main.async { NotificationCenter.default.post( @@ -139,8 +189,72 @@ extension WatchSessionService: WCSessionDelegate { } } +// MARK: - WatchHealthSummary + +struct WatchHealthSummary { + let avgHeartRate: Double + let maxHeartRate: Double + let minHeartRate: Double? + let totalCalories: Double + let heartRateZones: [[String: Any]] + let heartRateSamples: [[String: Any]] + let timestamp: Date + + /// heartRateZones 배열에서 zone별 초(seconds) 추출 + var zoneDurations: [Int: Int] { + var durations: [Int: Int] = [:] + for zone in heartRateZones { + if let zoneNum = zone["zone"] as? Int, + let seconds = zone["durationSeconds"] as? Int { + durations[zoneNum] = seconds + } + } + return durations + } + + static func fromDictionary(_ dict: [String: Any]) -> WatchHealthSummary? { + guard let avgHeartRate = dict["avgHeartRate"] as? Double, + let maxHeartRate = dict["maxHeartRate"] as? Double, + let totalCalories = dict["totalCalories"] as? Double, + let timestamp = dict["timestamp"] as? TimeInterval else { + return nil + } + + let zones = dict["heartRateZones"] as? [[String: Any]] ?? [] + let minHeartRate = dict["minHeartRate"] as? Double + let samples = dict["heartRateSamples"] as? [[String: Any]] ?? [] + + return WatchHealthSummary( + avgHeartRate: avgHeartRate, + maxHeartRate: maxHeartRate, + minHeartRate: minHeartRate, + totalCalories: totalCalories, + heartRateZones: zones, + heartRateSamples: samples, + timestamp: Date(timeIntervalSince1970: timestamp) + ) + } + + func toDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "avgHeartRate": avgHeartRate, + "maxHeartRate": maxHeartRate, + "totalCalories": totalCalories, + "heartRateZones": heartRateZones, + "heartRateSamples": heartRateSamples, + "timestamp": timestamp.timeIntervalSince1970 + ] + if let minHeartRate { + dict["minHeartRate"] = minHeartRate + } + return dict + } +} + // MARK: - Notification Names extension Notification.Name { static let watchCommandReceived = Notification.Name("watchCommandReceived") + static let watchRealtimeHealthReceived = Notification.Name("watchRealtimeHealthReceived") + static let watchHealthSummaryReceived = Notification.Name("watchHealthSummaryReceived") } diff --git a/Runnect-iOS/Runnect-iOS/Presentation/MyPage/VC/InfoVC/ActivityRecordDetailVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/MyPage/VC/InfoVC/ActivityRecordDetailVC.swift index c98ed81c..c01b3467 100644 --- a/Runnect-iOS/Runnect-iOS/Presentation/MyPage/VC/InfoVC/ActivityRecordDetailVC.swift +++ b/Runnect-iOS/Runnect-iOS/Presentation/MyPage/VC/InfoVC/ActivityRecordDetailVC.swift @@ -102,6 +102,56 @@ final class ActivityRecordDetailVC: UIViewController { $0.distribution = .fill } + // MARK: - Health Data UI Components + + private let healthDividerView = UIView().then { + $0.backgroundColor = .g5 + $0.isHidden = true + } + + private let healthTitleIcon = UIImageView().then { + $0.image = UIImage(systemName: "heart.fill") + $0.tintColor = .m1 + $0.contentMode = .scaleAspectFit + $0.isHidden = true + } + + private let healthTitleLabel = UILabel().then { + $0.text = "건강 데이터" + $0.font = .h5 + $0.textColor = .g1 + $0.isHidden = true + } + + private let healthAvgHRStatsView = StatsInfoView(title: "평균 심박수", stats: "-- BPM") + .setAttributedStats(stats: "--", unit: " BPM") + private let healthCalorieStatsView = StatsInfoView(title: "칼로리", stats: "-- kcal") + .setAttributedStats(stats: "--", unit: " kcal") + private let healthMaxHRStatsView = StatsInfoView(title: "최대 심박수", stats: "-- BPM") + .setAttributedStats(stats: "--", unit: " BPM") + + private let healthVertDivider1 = UIView().then { $0.backgroundColor = .g2 } + private let healthVertDivider2 = UIView().then { $0.backgroundColor = .g2 } + + private lazy var healthStatsStackView = UIStackView( + arrangedSubviews: [healthAvgHRStatsView, + healthVertDivider1, + healthCalorieStatsView, + healthVertDivider2, + healthMaxHRStatsView] + ).then { + $0.spacing = 25 + $0.isHidden = true + } + + private let heartRateZoneBarView = HeartRateZoneBarView().then { + $0.isHidden = true + } + + private var hasHealthData = false + + // MARK: - Finish Edit Button + private lazy var finishEditButton = CustomButton(title: "완료").then { $0.isHidden = true $0.isEnabled = false @@ -120,6 +170,10 @@ final class ActivityRecordDetailVC: UIViewController { self.view = view self.setKeyboardNotification() self.setTapGesture() + + if hasHealthData, let recordId = self.recordId { + fetchHealthData(recordId: recordId) + } } } @@ -235,19 +289,20 @@ extension ActivityRecordDetailVC { self.recordId = model.id self.mapImageView.setImage(with: model.image) self.courseTitleLabel.text = model.title - + self.hasHealthData = model.healthData != nil + let location = "\(model.departure.region) \(model.departure.city)" self.recordDepartureInfoView.setDescriptionText(description: location) - + // 날짜 바꾸기 let recordDate = model.createdAt.prefix(10) let resultDate = RNTimeFormatter.changeDateSplit(date: String(recordDate)) self.recordDateInfoView.setDescriptionText(description: resultDate) - + // 이동 시간 바꾸기 let recordRunningTime = model.time.suffix(7) self.recordRunningTimeValueLabel.text = String(recordRunningTime) - + // 평균 페이스 바꾸기 let array = spiltRecordAveragePace(model: model) setUpRecordAveragePaceValueLabel(array: array, label: recordAveragePaceValueLabel) @@ -406,27 +461,87 @@ extension ActivityRecordDetailVC { private func setRecordSubInfoStackView() { middleScorollView.addSubview(recordSubInfoStackView) - + let screenWidth = UIScreen.main.bounds.width let containerViewWidth = screenWidth - 32 let stackViewWidth = Int(containerViewWidth - 2) / 3 - + recordDistanceStackView.snp.makeConstraints { $0.width.equalTo(stackViewWidth) } - + recordRunningTimeStackView.snp.makeConstraints { $0.width.equalTo(stackViewWidth) } - + recordAveragePaceStackView.snp.makeConstraints { $0.width.equalTo(stackViewWidth) } - + recordSubInfoStackView.snp.makeConstraints { $0.top.equalTo(secondHorizontalDivideLine.snp.bottom).offset(23) $0.centerX.equalToSuperview() - $0.bottom.equalToSuperview().inset(30) + } + + setHealthDataLayout() + } + + private func setHealthDataLayout() { + middleScorollView.addSubviews( + healthDividerView, + healthTitleIcon, + healthTitleLabel, + healthVertDivider1, + healthVertDivider2, + healthStatsStackView, + heartRateZoneBarView + ) + + healthDividerView.snp.makeConstraints { + $0.top.equalTo(recordSubInfoStackView.snp.bottom).offset(25) + $0.leading.trailing.equalTo(view.safeAreaLayoutGuide) + $0.height.equalTo(7) + } + + healthTitleIcon.snp.makeConstraints { + $0.top.equalTo(healthDividerView.snp.bottom).offset(20) + $0.leading.equalTo(view.safeAreaLayoutGuide).inset(16) + $0.width.height.equalTo(16) + } + + healthTitleLabel.snp.makeConstraints { + $0.centerY.equalTo(healthTitleIcon) + $0.leading.equalTo(healthTitleIcon.snp.trailing).offset(6) + } + + healthVertDivider1.snp.makeConstraints { + $0.height.equalTo(44) + $0.width.equalTo(0.5) + } + + healthVertDivider2.snp.makeConstraints { + $0.height.equalTo(44) + $0.width.equalTo(0.5) + } + + healthStatsStackView.snp.makeConstraints { + $0.top.equalTo(healthTitleLabel.snp.bottom).offset(16) + $0.centerX.equalToSuperview() + } + + heartRateZoneBarView.snp.makeConstraints { + $0.top.equalTo(healthStatsStackView.snp.bottom).offset(20) + $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + // 건강 데이터가 있으면 zoneBarView가 bottom, 없으면 recordSubInfo가 bottom + heartRateZoneBarView.snp.makeConstraints { + $0.bottom.lessThanOrEqualToSuperview().inset(30) + } + + // 건강 데이터 없을 때의 bottom 제약 + recordSubInfoStackView.snp.makeConstraints { + $0.bottom.lessThanOrEqualToSuperview().inset(30) } } @@ -517,6 +632,60 @@ extension ActivityRecordDetailVC { } } + private func fetchHealthData(recordId: Int) { + recordProvider.request(.getHealthData(recordId: recordId)) { [weak self] response in + guard let self = self else { return } + switch response { + case .success(let result): + if 200..<300 ~= result.statusCode { + do { + let responseDto = try result.map(BaseResponse.self) + guard let healthData = responseDto.data?.healthData else { return } + self.showHealthDetail(healthData) + } catch { + print("[HealthData] Decode error: \(error)") + } + } + case .failure(let error): + print("[HealthData] Fetch error: \(error.localizedDescription)") + } + } + } + + private func showHealthDetail(_ detail: HealthDataDetail) { + healthDividerView.isHidden = false + healthTitleIcon.isHidden = false + healthTitleLabel.isHidden = false + healthStatsStackView.isHidden = false + + healthAvgHRStatsView.setAttributedStats(stats: "\(Int(detail.avgHeartRate))", unit: " BPM") + healthCalorieStatsView.setAttributedStats(stats: "\(Int(detail.calories))", unit: " kcal") + healthMaxHRStatsView.setAttributedStats(stats: "\(Int(detail.maxHeartRate))", unit: " BPM") + + // zone seconds → percentage 변환 + let zones = detail.zones + let totalSeconds = zones.zone1Seconds + zones.zone2Seconds + + zones.zone3Seconds + zones.zone4Seconds + zones.zone5Seconds + guard totalSeconds > 0 else { return } + + var zoneData: [[String: Any]] = [] + let zoneSeconds = [ + (1, zones.zone1Seconds), + (2, zones.zone2Seconds), + (3, zones.zone3Seconds), + (4, zones.zone4Seconds), + (5, zones.zone5Seconds) + ] + for (zone, seconds) in zoneSeconds where seconds > 0 { + zoneData.append([ + "zone": zone, + "percentage": Double(seconds) / Double(totalSeconds) * 100.0 + ]) + } + + heartRateZoneBarView.configure(with: zoneData) + } + private func editRecordTitle() { guard let recordId = self.recordId else { return } guard let editRecordTitle = self.courseTitleTextField.text else { return } diff --git a/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunTrackingVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunTrackingVC.swift index 86a7633d..3e25914d 100644 --- a/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunTrackingVC.swift +++ b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunTrackingVC.swift @@ -30,6 +30,9 @@ final class RunTrackingVC: UIViewController { private var lastLocation: CLLocation? private var runDistance: Double = 0.0 // meters private let distanceQueue = DispatchQueue(label: "com.runnect.distance", qos: .userInitiated) + + // Watch 건강 데이터 + private var healthCancellables = Set() // MARK: - UI Components @@ -119,7 +122,102 @@ final class RunTrackingVC: UIViewController { private let smallStarImageView = UIImageView().then { $0.image = ImageLiterals.icStar } - + + // Watch 건강 데이터 UI (statsView 내부에 배치) + private let healthDividerLine = UIView().then { + $0.backgroundColor = .g4 + $0.isHidden = true + } + + private let heartRateImageView = UIImageView().then { + $0.image = UIImage(systemName: "heart.fill") + $0.tintColor = .g2 + $0.contentMode = .scaleAspectFit + } + + private let heartRateTitleLabel = UILabel().then { + $0.text = "심박수" + $0.font = .b4 + $0.textColor = .g2 + } + + private lazy var heartRateInfoStackView = UIStackView( + arrangedSubviews: [heartRateImageView, heartRateTitleLabel] + ).then { + $0.spacing = 8 + $0.alignment = .leading + } + + private let heartRateValueLabel = UILabel().then { + $0.attributedText = { + let attr = NSMutableAttributedString( + string: "--", + attributes: [.font: UIFont.h3, .foregroundColor: UIColor.g1] + ) + attr.append(NSAttributedString( + string: " BPM", + attributes: [.font: UIFont.b4, .foregroundColor: UIColor.g2] + )) + return attr + }() + } + + private lazy var heartRateStatsStackView = UIStackView( + arrangedSubviews: [heartRateInfoStackView, heartRateValueLabel] + ).then { + $0.axis = .vertical + $0.alignment = .leading + $0.spacing = 14 + } + + private let calorieImageView = UIImageView().then { + $0.image = UIImage(systemName: "flame.fill") + $0.tintColor = .g2 + $0.contentMode = .scaleAspectFit + } + + private let calorieTitleLabel = UILabel().then { + $0.text = "칼로리" + $0.font = .b4 + $0.textColor = .g2 + } + + private lazy var calorieInfoStackView = UIStackView( + arrangedSubviews: [calorieImageView, calorieTitleLabel] + ).then { + $0.spacing = 8 + $0.alignment = .leading + } + + private let calorieValueLabel = UILabel().then { + $0.attributedText = { + let attr = NSMutableAttributedString( + string: "0", + attributes: [.font: UIFont.h3, .foregroundColor: UIColor.g1] + ) + attr.append(NSAttributedString( + string: " kcal", + attributes: [.font: UIFont.b4, .foregroundColor: UIColor.g2] + )) + return attr + }() + } + + private lazy var calorieStatsStackView = UIStackView( + arrangedSubviews: [calorieInfoStackView, calorieValueLabel] + ).then { + $0.axis = .vertical + $0.alignment = .leading + $0.spacing = 14 + } + + private lazy var healthStatsStackView = UIStackView( + arrangedSubviews: [heartRateStatsStackView, calorieStatsStackView] + ).then { + $0.spacing = 38 + $0.isHidden = true + } + private let mapView = RNMapView() .showLocationButton(toShow: true) .makeContentPadding(padding: UIEdgeInsets(top: 100, left: 0, bottom: 0, right: 0)) @@ -136,6 +234,7 @@ final class RunTrackingVC: UIViewController { self.setAddTarget() self.bindStopwatch() self.observeWatchCommand() + self.bindWatchHealthData() } override func viewWillAppear(_ animated: Bool) { @@ -148,6 +247,7 @@ final class RunTrackingVC: UIViewController { deinit { NotificationCenter.default.removeObserver(self) cancelBag.cancel() + healthCancellables.forEach { $0.cancel() } stopRunLocationTracking() WatchSessionService.shared.stopSendingRunningData() } @@ -250,11 +350,87 @@ extension RunTrackingVC { ) } + private func bindWatchHealthData() { + let watchService = WatchSessionService.shared + + watchService.$realtimeHeartRate + .receive(on: DispatchQueue.main) + .sink { [weak self] heartRate in + guard let self else { return } + if heartRate > 0 { + self.showHealthDataRow() + self.heartRateValueLabel.attributedText = self.makeAttributedHealthValue( + value: "\(Int(heartRate))", unit: " BPM" + ) + } + } + .store(in: &healthCancellables) + + watchService.$realtimeCalories + .receive(on: DispatchQueue.main) + .sink { [weak self] calories in + guard let self else { return } + if calories > 0 { + self.calorieValueLabel.attributedText = self.makeAttributedHealthValue( + value: "\(Int(calories))", unit: " kcal" + ) + } + } + .store(in: &healthCancellables) + + watchService.$isWatchReachable + .receive(on: DispatchQueue.main) + .sink { [weak self] reachable in + guard let self else { return } + if !reachable { + self.hideHealthDataRow() + } + } + .store(in: &healthCancellables) + } + + private func makeAttributedHealthValue(value: String, unit: String) -> NSMutableAttributedString { + let attr = NSMutableAttributedString( + string: value, + attributes: [.font: UIFont.h3, .foregroundColor: UIColor.g1] + ) + attr.append(NSAttributedString( + string: unit, + attributes: [.font: UIFont.b4, .foregroundColor: UIColor.g2] + )) + return attr + } + + private func showHealthDataRow() { + guard healthStatsStackView.isHidden else { return } + healthDividerLine.isHidden = false + healthStatsStackView.isHidden = false + statsView.snp.updateConstraints { + $0.height.equalTo(160) + } + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() + } + } + + private func hideHealthDataRow() { + guard !healthStatsStackView.isHidden else { return } + healthDividerLine.isHidden = true + healthStatsStackView.isHidden = true + statsView.snp.updateConstraints { + $0.height.equalTo(100) + } + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() + } + } + private func pushToRunningRecordVC() { guard var runningModel = self.runningModel else { return } - + runningModel.totalTime = self.totalTime - + runningModel.healthSummary = WatchSessionService.shared.healthSummary + let runningRecordVC = RunningRecordVC() runningRecordVC.setData(runningModel: runningModel) self.navigationController?.pushViewController(runningRecordVC, animated: true) @@ -274,6 +450,7 @@ extension RunTrackingVC { self?.stopRunLocationTracking() WatchSessionService.shared.stopSendingRunningData() WatchSessionService.shared.sendRunReset() + WatchSessionService.shared.clearHealthData() self?.navigationController?.popViewController(animated: true) } self.present(alertVC, animated: false) @@ -337,38 +514,58 @@ extension RunTrackingVC { private func setLayout() { view.addSubviews(mapView, statsView, runningCompleteButton) - statsView.addSubviews(backButton, statsStackView, bigStarImageView, smallStarImageView) - + statsView.addSubviews(backButton, statsStackView, bigStarImageView, smallStarImageView, healthDividerLine, healthStatsStackView) + statsView.snp.makeConstraints { $0.leading.top.trailing.equalTo(view.safeAreaLayoutGuide) $0.height.equalTo(100) } - + backButton.snp.makeConstraints { $0.leading.top.equalToSuperview() $0.width.height.equalTo(48) } - + statsStackView.snp.makeConstraints { $0.leading.equalTo(backButton.snp.trailing) $0.top.equalToSuperview().inset(15) } - + bigStarImageView.snp.makeConstraints { $0.centerY.equalTo(timeStatsLabel.snp.centerY) $0.trailing.equalToSuperview().inset(15) } - + smallStarImageView.snp.makeConstraints { $0.top.equalTo(bigStarImageView.snp.bottom).offset(2) $0.centerX.equalTo(bigStarImageView.snp.leading).multipliedBy(0.99) } - + + healthDividerLine.snp.makeConstraints { + $0.top.equalTo(statsStackView.snp.bottom).offset(8) + $0.leading.equalTo(backButton.snp.trailing) + $0.trailing.equalToSuperview().inset(16) + $0.height.equalTo(1) + } + + heartRateImageView.snp.makeConstraints { + $0.width.height.equalTo(14) + } + + calorieImageView.snp.makeConstraints { + $0.width.height.equalTo(14) + } + + healthStatsStackView.snp.makeConstraints { + $0.top.equalTo(healthDividerLine.snp.bottom).offset(8) + $0.leading.equalTo(backButton.snp.trailing) + } + mapView.snp.makeConstraints { $0.leading.top.trailing.equalTo(view.safeAreaLayoutGuide) $0.bottom.equalToSuperview() } - + runningCompleteButton.snp.makeConstraints { $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) $0.height.equalTo(44) diff --git a/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift index 09014d21..38983d10 100644 --- a/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift +++ b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift @@ -72,7 +72,56 @@ final class RunningRecordVC: UIViewController { ).then { $0.spacing = 25 } - + + // Watch 건강 데이터 섹션 + private let healthDividerView = UIView().then { + $0.backgroundColor = .g5 + $0.isHidden = true + } + + private let healthTitleIcon = UIImageView().then { + $0.image = UIImage(systemName: "heart.fill") + $0.tintColor = .m1 + $0.contentMode = .scaleAspectFit + $0.isHidden = true + } + + private let healthTitleLabel = UILabel().then { + $0.text = "건강 데이터" + $0.font = .h5 + $0.textColor = .g1 + $0.isHidden = true + } + + private let heartRateStatsView = StatsInfoView(title: "평균 심박수", stats: "-- BPM") + .setAttributedStats(stats: "--", unit: " BPM") + private let calorieStatsView = StatsInfoView(title: "칼로리", stats: "-- kcal") + .setAttributedStats(stats: "--", unit: " kcal") + private let maxHeartRateStatsView = StatsInfoView(title: "최대 심박수", stats: "-- BPM") + .setAttributedStats(stats: "--", unit: " BPM") + + private let healthVerticalDividerView = UIView().then { + $0.backgroundColor = .g2 + } + private let healthVerticalDividerView2 = UIView().then { + $0.backgroundColor = .g2 + } + + private lazy var healthStatsContainerStackView = UIStackView( + arrangedSubviews: [heartRateStatsView, + healthVerticalDividerView, + calorieStatsView, + healthVerticalDividerView2, + maxHeartRateStatsView] + ).then { + $0.spacing = 25 + $0.isHidden = true + } + + private let heartRateZoneBarView = HeartRateZoneBarView().then { + $0.isHidden = true + } + private let saveButton = CustomButton(title: "저장하기") .setEnabled(false) @@ -141,13 +190,33 @@ extension RunningRecordVC { self.totalTimeStatsView.setStats(stats: runningModel.getFormattedTotalTime() ?? "00:00:00") self.averagePaceStatsView.setStats(stats: runningModel.getFormattedAveragePage() ?? "0'00''") self.courseImageView.image = runningModel.pathImage - + + // 건강 데이터 표시 (Watch 연결 시) + if let health = runningModel.healthSummary { + showHealthData(health) + } + guard let region = runningModel.region, let city = runningModel.city else { return } self.departureInfoView.setDescriptionText(description: "\(region) \(city)") - + guard let imageUrl = runningModel.imageUrl else { return } self.courseImageView.setImage(with: imageUrl) } + + private func showHealthData(_ summary: WatchHealthSummary) { + healthDividerView.isHidden = false + healthTitleIcon.isHidden = false + healthTitleLabel.isHidden = false + healthStatsContainerStackView.isHidden = false + + heartRateStatsView.setAttributedStats(stats: "\(Int(summary.avgHeartRate))", unit: " BPM") + calorieStatsView.setAttributedStats(stats: "\(Int(summary.totalCalories))", unit: " kcal") + maxHeartRateStatsView.setAttributedStats(stats: "\(Int(summary.maxHeartRate))", unit: " BPM") + + if !summary.heartRateZones.isEmpty { + heartRateZoneBarView.configure(with: summary.heartRateZones) + } + } } // MARK: - @objc Function @@ -237,51 +306,96 @@ extension RunningRecordVC { dividerView, verticalDividerView, verticalDividerView2, - statsContainerStackView + statsContainerStackView, + healthDividerView, + healthTitleIcon, + healthTitleLabel, + healthVerticalDividerView, + healthVerticalDividerView2, + healthStatsContainerStackView, + heartRateZoneBarView ) - + courseImageView.snp.makeConstraints { $0.top.leading.trailing.equalToSuperview() $0.height.equalTo(courseImageView.snp.width) } - + courseTitleTextField.snp.makeConstraints { $0.top.equalTo(courseImageView.snp.bottom).offset(27) $0.leading.trailing.equalToSuperview().inset(16) $0.height.equalTo(35) } - + dateInfoView.snp.makeConstraints { $0.top.equalTo(courseTitleTextField.snp.bottom).offset(22) $0.leading.trailing.equalToSuperview().inset(16) $0.height.equalTo(16) } - + departureInfoView.snp.makeConstraints { $0.top.equalTo(dateInfoView.snp.bottom).offset(6) $0.leading.trailing.equalToSuperview().inset(16) $0.height.equalTo(16) } - + dividerView.snp.makeConstraints { $0.top.equalTo(departureInfoView.snp.bottom).offset(34) $0.leading.trailing.equalToSuperview() $0.height.equalTo(7) } - + verticalDividerView.snp.makeConstraints { $0.height.equalTo(44) $0.width.equalTo(0.5) } - + verticalDividerView2.snp.makeConstraints { $0.height.equalTo(44) $0.width.equalTo(0.5) } - + statsContainerStackView.snp.makeConstraints { $0.top.equalTo(dividerView.snp.bottom).offset(25) $0.centerX.equalToSuperview() + } + + // 건강 데이터 섹션 (Watch 연결 시에만 표시) + healthDividerView.snp.makeConstraints { + $0.top.equalTo(statsContainerStackView.snp.bottom).offset(25) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(7) + } + + healthTitleIcon.snp.makeConstraints { + $0.top.equalTo(healthDividerView.snp.bottom).offset(20) + $0.leading.equalToSuperview().inset(16) + $0.width.height.equalTo(16) + } + + healthTitleLabel.snp.makeConstraints { + $0.centerY.equalTo(healthTitleIcon) + $0.leading.equalTo(healthTitleIcon.snp.trailing).offset(6) + } + + healthVerticalDividerView.snp.makeConstraints { + $0.height.equalTo(44) + $0.width.equalTo(0.5) + } + + healthVerticalDividerView2.snp.makeConstraints { + $0.height.equalTo(44) + $0.width.equalTo(0.5) + } + + healthStatsContainerStackView.snp.makeConstraints { + $0.top.equalTo(healthTitleLabel.snp.bottom).offset(16) + $0.centerX.equalToSuperview() + } + + heartRateZoneBarView.snp.makeConstraints { + $0.top.equalTo(healthStatsContainerStackView.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(16) $0.bottom.lessThanOrEqualToSuperview().inset(25) } } @@ -295,40 +409,135 @@ extension RunningRecordVC { extension RunningRecordVC { private func recordRunning() { - guard let runningModel = self.runningModel else { return } - guard let courseId = runningModel.courseId else { return } - guard let titleText = courseTitleTextField.text else { return } - guard let time = runningModel.getFormattedTotalTime() else { return } - guard let secondsPerKm = runningModel.getIntPace() else { return } + guard let runningModel = self.runningModel, + let courseId = runningModel.courseId, + let titleText = courseTitleTextField.text, + let time = runningModel.getFormattedTotalTime(), + let secondsPerKm = runningModel.getIntPace() else { return } + let pace = RNTimeFormatter.secondsToHHMMSS(seconds: secondsPerKm) - let requestDto = RunningRecordRequestDto(courseId: courseId, - publicCourseId: runningModel.publicCourseId, - title: titleText, - time: time, - pace: pace) + let requestDto = RunningRecordRequestDto( + courseId: courseId, + publicCourseId: runningModel.publicCourseId, + title: titleText, + time: time, + pace: pace + ) LoadingIndicator.showLoading() + + // Step 1: 러닝 기록 저장 recordProvider.request(.recordRunning(param: requestDto)) { [weak self] response in guard let self = self else { return } - LoadingIndicator.hideLoading() switch response { case .success(let result): let status = result.statusCode if 200..<300 ~= status { - analyze(buttonName: GAEvent.Button.clickStoreRunningTracking) - WatchSessionService.shared.sendRunReset() - self.showToastOnWindow(text: "저장한 러닝 기록은 마이페이지에서 볼 수 있어요.") - self.navigationController?.popToRootViewController(animated: true) + do { + let responseDto = try result.map(BaseResponse.self) + guard let recordId = responseDto.data?.record.id else { + self.handleRecordSaveSuccess() + return + } + + // Step 2: 건강 데이터 저장 (Watch 연결 시에만) + if let summary = runningModel.healthSummary { + self.saveHealthData(recordId: recordId, summary: summary) + } else { + self.handleRecordSaveSuccess() + } + } catch { + print("[RecordRunning] Response decode error: \(error)") + self.handleRecordSaveSuccess() + } } if status >= 400 { - print("400 error") + LoadingIndicator.hideLoading() self.showNetworkFailureToast() } case .failure(let error): + LoadingIndicator.hideLoading() print(error.localizedDescription) self.showNetworkFailureToast() } } } + + private func saveHealthData(recordId: Int, summary: WatchHealthSummary) { + let zoneDurations = summary.zoneDurations + + var sampleDtos: [HeartRateSampleDto]? + if !summary.heartRateSamples.isEmpty { + sampleDtos = summary.heartRateSamples.compactMap { dict in + guard let heartRate = dict["heartRate"] as? Double, + let elapsedSeconds = dict["elapsedSeconds"] as? Int, + let zone = dict["zone"] as? Int else { return nil } + return HeartRateSampleDto( + heartRate: heartRate, + elapsedSeconds: elapsedSeconds, + zone: zone + ) + } + } + + let healthDto = HealthDataSaveRequestDto( + avgHeartRate: summary.avgHeartRate, + maxHeartRate: summary.maxHeartRate, + minHeartRate: summary.minHeartRate, + calories: summary.totalCalories, + zone1Seconds: zoneDurations[1] ?? 0, + zone2Seconds: zoneDurations[2] ?? 0, + zone3Seconds: zoneDurations[3] ?? 0, + zone4Seconds: zoneDurations[4] ?? 0, + zone5Seconds: zoneDurations[5] ?? 0, + maxHeartRateConfig: nil, + heartRateSamples: sampleDtos + ) + + recordProvider.request(.saveHealthData(recordId: recordId, param: healthDto)) { [weak self] response in + guard let self = self else { return } + switch response { + case .success(let result): + if 200..<300 ~= result.statusCode { + self.handleRecordSaveSuccess() + } else if result.statusCode == 409 { + self.deleteAndResaveHealthData(recordId: recordId, healthDto: healthDto) + } else { + print("[HealthData] Save failed: \(result.statusCode)") + self.handleRecordSaveSuccess() + } + case .failure(let error): + print("[HealthData] Network error: \(error.localizedDescription)") + self.handleRecordSaveSuccess() + } + } + } + + private func deleteAndResaveHealthData(recordId: Int, healthDto: HealthDataSaveRequestDto) { + recordProvider.request(.deleteHealthData(recordId: recordId)) { [weak self] response in + guard let self = self else { return } + switch response { + case .success(let result): + if 200..<300 ~= result.statusCode { + self.recordProvider.request(.saveHealthData(recordId: recordId, param: healthDto)) { [weak self] _ in + self?.handleRecordSaveSuccess() + } + } else { + self.handleRecordSaveSuccess() + } + case .failure: + self.handleRecordSaveSuccess() + } + } + } + + private func handleRecordSaveSuccess() { + LoadingIndicator.hideLoading() + analyze(buttonName: GAEvent.Button.clickStoreRunningTracking) + WatchSessionService.shared.sendRunReset() + WatchSessionService.shared.clearHealthData() + showToastOnWindow(text: "저장한 러닝 기록은 마이페이지에서 볼 수 있어요.") + navigationController?.popToRootViewController(animated: true) + } }