From 72e461b85f071b167046b5e924b92f60fe2efa4d Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Sun, 22 Feb 2026 21:27:15 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Apple=20Watch=20=EA=B1=B4=EA=B0=95?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A7=91=20=EB=B0=8F?= =?UTF-8?q?=20iPhone=20=EC=A0=84=EB=8B=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkoutManager에 심박수 샘플 추적 및 Zone 분포 계산 추가 - WatchSessionManager에 transferUserInfo 기반 건강 요약 전달 구현 - WatchSessionService에 didReceiveUserInfo 및 실시간 건강 데이터 수신 구현 - RunningModel에 healthSummary 필드 추가 - RunningRecordRequestDto에 HealthDataRequestDto 추가 --- .../Connectivity/WatchSessionManager.swift | 20 ++++ .../Workout/WorkoutManager.swift | 97 +++++++++++++++++ .../RequestDto/RunningRecordRequestDto.swift | 9 ++ .../Model/RunningModel/RunningModel.swift | 3 + .../Network/Service/WatchSessionService.swift | 101 +++++++++++++++++- 5 files changed, 225 insertions(+), 5 deletions(-) diff --git a/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift b/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift index 48cf6dd5..2f4a28b8 100644 --- a/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift @@ -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/Workout/WorkoutManager.swift b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift index 031ef52c..fc2756d8 100644 --- a/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift @@ -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,74 @@ final class WorkoutManager: NSObject, ObservableObject { } } + // MARK: - Health Summary + + func generateHealthSummary() -> [String: Any] { + let avgHR = summaryHeartRate + let maxHR = summaryMaxHeartRate + 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 + ]) + } + + return [ + "messageType": "healthSummary", + "avgHeartRate": round(avgHR * 10) / 10, + "maxHeartRate": round(maxHR * 10) / 10, + "totalCalories": round(calories * 10) / 10, + "heartRateZones": zoneList, + "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 +209,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 +252,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/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift index 8a9834da..9473d6e1 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift @@ -13,4 +13,13 @@ struct RunningRecordRequestDto: Codable { let courseId: Int let publicCourseId: Int? let title, time, pace: String + let healthData: HealthDataRequestDto? +} + +// MARK: - HealthDataRequestDto + +struct HealthDataRequestDto: Codable { + let avgHeartRate: Double + let maxHeartRate: Double + let totalCalories: Double } 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/Service/WatchSessionService.swift b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift index f02377b5..55082334 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift @@ -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,49 @@ extension WatchSessionService: WCSessionDelegate { } } +// MARK: - WatchHealthSummary + +struct WatchHealthSummary { + let avgHeartRate: Double + let maxHeartRate: Double + let totalCalories: Double + let heartRateZones: [[String: Any]] + let timestamp: Date + + 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]] ?? [] + + return WatchHealthSummary( + avgHeartRate: avgHeartRate, + maxHeartRate: maxHeartRate, + totalCalories: totalCalories, + heartRateZones: zones, + timestamp: Date(timeIntervalSince1970: timestamp) + ) + } + + func toDictionary() -> [String: Any] { + return [ + "avgHeartRate": avgHeartRate, + "maxHeartRate": maxHeartRate, + "totalCalories": totalCalories, + "heartRateZones": heartRateZones, + "timestamp": timestamp.timeIntervalSince1970 + ] + } +} + // MARK: - Notification Names extension Notification.Name { static let watchCommandReceived = Notification.Name("watchCommandReceived") + static let watchRealtimeHealthReceived = Notification.Name("watchRealtimeHealthReceived") + static let watchHealthSummaryReceived = Notification.Name("watchHealthSummaryReceived") } From 1d93e4dd39e91927f8e241dbfc2366f0f49c7d02 Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Sun, 22 Feb 2026 21:27:25 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=9F=AC=EB=8B=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B1=B4=EA=B0=95=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunTrackingVC에 실시간 심박수/칼로리 표시 추가 - RunningRecordVC에 건강 데이터 요약 섹션 추가 - HeartRateZoneBarView 신규 컴포넌트 구현 - 기존 브랜드 컬러/폰트 시스템 일관성 유지 --- .../Runnect-iOS.xcodeproj/project.pbxproj | 4 + .../UIComponents/HeartRateZoneBarView.swift | 171 ++++++++++++++ .../Running/VC/RunTrackingVC.swift | 219 +++++++++++++++++- .../Running/VC/RunningRecordVC.swift | 162 +++++++++++-- 4 files changed, 528 insertions(+), 28 deletions(-) create mode 100644 Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift diff --git a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj index 24ee32de..5fcfd5b6 100644 --- a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj +++ b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj @@ -150,6 +150,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 */; }; @@ -342,6 +343,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 = ""; }; @@ -1111,6 +1113,7 @@ CE0D9FD229648DA300CEB5CD /* CustomAlertVC.swift */, CE9291242965C9FB0010959C /* CourseDetailInfoView.swift */, CE9291262965D0ED0010959C /* StatsInfoView.swift */, + D1A2B3C4D5E6F7081234ABCD /* HeartRateZoneBarView.swift */, CE6B63D729673450003F900F /* ListEmptyView.swift */, CEB0BCBB29D123350048CCD5 /* GuideView.swift */, A3C2CAD629E53B2900EC525B /* RNAlertVC.swift */, @@ -1426,6 +1429,7 @@ 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..23bf86fb --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift @@ -0,0 +1,171 @@ +// +// HeartRateZoneBarView.swift +// Runnect-iOS +// +// Created by Runnect 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/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..abb78c7a 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) } } @@ -302,11 +416,24 @@ extension RunningRecordVC { guard 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) + // 건강 데이터 (Watch 미연결 시 nil) + var healthData: HealthDataRequestDto? + if let summary = runningModel.healthSummary { + healthData = HealthDataRequestDto( + avgHeartRate: summary.avgHeartRate, + maxHeartRate: summary.maxHeartRate, + totalCalories: summary.totalCalories + ) + } + + let requestDto = RunningRecordRequestDto( + courseId: courseId, + publicCourseId: runningModel.publicCourseId, + title: titleText, + time: time, + pace: pace, + healthData: healthData + ) LoadingIndicator.showLoading() recordProvider.request(.recordRunning(param: requestDto)) { [weak self] response in @@ -318,6 +445,7 @@ extension RunningRecordVC { if 200..<300 ~= status { analyze(buttonName: GAEvent.Button.clickStoreRunningTracking) WatchSessionService.shared.sendRunReset() + WatchSessionService.shared.clearHealthData() self.showToastOnWindow(text: "저장한 러닝 기록은 마이페이지에서 볼 수 있어요.") self.navigationController?.popToRootViewController(animated: true) } From 0ce53580ce8c99183a4b4fd633d7f0cb88b7751e Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Sun, 22 Feb 2026 22:32:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20Health=20Data?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=202-step=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecordRouter에 건강 데이터 API 엔드포인트 4개 추가 - POST /record 응답에서 recordId 파싱하여 2-step 저장 흐름 구현 - 409 Conflict 시 DELETE 후 재전송 retry 로직 추가 - WatchHealthSummary에 minHeartRate, heartRateSamples, zoneDurations 보강 - WorkoutManager에 개별 심박수 샘플 및 최저 심박수 포함 - ActivityRecordDetailVC에 과거 기록 건강 데이터 조회 및 표시 추가 - ActivityRecord에 optional healthData 필드 추가 (하위 호환) --- .../Workout/WorkoutManager.swift | 17 ++ .../Runnect-iOS.xcodeproj/project.pbxproj | 16 ++ .../Dto/MyPageDto/ActivityRecordInfoDto.swift | 8 + .../RequestDto/HealthDataSaveRequestDto.swift | 32 +++ .../RequestDto/RunningRecordRequestDto.swift | 9 - .../ResponseDto/HealthDataResponseDto.swift | 38 ++++ .../HealthSummaryResponseDto.swift | 19 ++ .../ResponseDto/RecordResponseDto.swift | 19 ++ .../Network/Router/RecordRouter.swift | 29 ++- .../Network/Service/WatchSessionService.swift | 25 ++- .../VC/InfoVC/ActivityRecordDetailVC.swift | 189 +++++++++++++++++- .../Running/VC/RunningRecordVC.swift | 129 +++++++++--- 12 files changed, 484 insertions(+), 46 deletions(-) create mode 100644 Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift create mode 100644 Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift create mode 100644 Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift create mode 100644 Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift diff --git a/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift index fc2756d8..86d6020c 100644 --- a/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift +++ b/Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift @@ -127,6 +127,7 @@ final class WorkoutManager: NSObject, ObservableObject { func generateHealthSummary() -> [String: Any] { let avgHR = summaryHeartRate let maxHR = summaryMaxHeartRate + let minHR = heartRateSamples.map(\.bpm).min() ?? 0 let calories = summaryCalories let zones = calculateZoneDistribution() @@ -140,12 +141,28 @@ final class WorkoutManager: NSObject, ObservableObject { ]) } + // 개별 심박수 샘플을 서버 전송 형식으로 변환 + 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 ] } diff --git a/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj b/Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj index 5fcfd5b6..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 */; }; @@ -299,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 = ""; }; @@ -902,6 +910,7 @@ isa = PBXGroup; children = ( CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */, + F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */, ); path = RequestDto; sourceTree = ""; @@ -910,6 +919,9 @@ isa = PBXGroup; children = ( CEF3CD99296DB305002723A1 /* CourseDetailResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */, + F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */, ); path = ResponseDto; sourceTree = ""; @@ -1426,6 +1438,10 @@ 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 */, 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..19b7babe --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift @@ -0,0 +1,32 @@ +// +// HealthDataSaveRequestDto.swift +// Runnect-iOS +// +// Created by Runnect 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: Int? + 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/RequestDto/RunningRecordRequestDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift index 9473d6e1..8a9834da 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift @@ -13,13 +13,4 @@ struct RunningRecordRequestDto: Codable { let courseId: Int let publicCourseId: Int? let title, time, pace: String - let healthData: HealthDataRequestDto? -} - -// MARK: - HealthDataRequestDto - -struct HealthDataRequestDto: Codable { - let avgHeartRate: Double - let maxHeartRate: Double - let totalCalories: Double } 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..6c1dc54a --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift @@ -0,0 +1,38 @@ +// +// HealthDataResponseDto.swift +// Runnect-iOS +// +// Created by Runnect 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: Int? + 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..30904a6a --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift @@ -0,0 +1,19 @@ +// +// HealthSummaryResponseDto.swift +// Runnect-iOS +// +// Created by Runnect on 2026/02/22. +// + +import Foundation + +// MARK: - HealthSummaryResponseDto + +struct HealthSummaryResponseDto: 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..33872b20 --- /dev/null +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift @@ -0,0 +1,19 @@ +// +// RecordResponseDto.swift +// Runnect-iOS +// +// Created by Runnect 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/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 55082334..7c31bd24 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift @@ -194,10 +194,24 @@ extension WatchSessionService: WCSessionDelegate { 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, @@ -207,24 +221,33 @@ struct WatchHealthSummary { } 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] { - return [ + 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 } } 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/RunningRecordVC.swift b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift index abb78c7a..38983d10 100644 --- a/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift +++ b/Runnect-iOS/Runnect-iOS/Presentation/Running/VC/RunningRecordVC.swift @@ -409,54 +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 } - let pace = RNTimeFormatter.secondsToHHMMSS(seconds: secondsPerKm) + guard let runningModel = self.runningModel, + let courseId = runningModel.courseId, + let titleText = courseTitleTextField.text, + let time = runningModel.getFormattedTotalTime(), + let secondsPerKm = runningModel.getIntPace() else { return } - // 건강 데이터 (Watch 미연결 시 nil) - var healthData: HealthDataRequestDto? - if let summary = runningModel.healthSummary { - healthData = HealthDataRequestDto( - avgHeartRate: summary.avgHeartRate, - maxHeartRate: summary.maxHeartRate, - totalCalories: summary.totalCalories - ) - } + let pace = RNTimeFormatter.secondsToHHMMSS(seconds: secondsPerKm) let requestDto = RunningRecordRequestDto( courseId: courseId, publicCourseId: runningModel.publicCourseId, title: titleText, time: time, - pace: pace, - healthData: healthData + 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() - WatchSessionService.shared.clearHealthData() - 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) + } } From 0d09bac3121639763ac931364674c4c681cf6587 Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Mon, 23 Feb 2026 21:24:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?DTO=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - maxHeartRateConfig 타입 Int → Double (서버가 190.0으로 반환) - HealthSummaryResponseDto에 summary nested 구조 반영 --- .../RunningDto/RequestDto/HealthDataSaveRequestDto.swift | 2 +- .../Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift | 2 +- .../RunningDto/ResponseDto/HealthSummaryResponseDto.swift | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift index 19b7babe..1c7b8e2e 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift @@ -19,7 +19,7 @@ struct HealthDataSaveRequestDto: Codable { let zone3Seconds: Int let zone4Seconds: Int let zone5Seconds: Int - let maxHeartRateConfig: Int? + let maxHeartRateConfig: Double? let heartRateSamples: [HeartRateSampleDto]? } diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift index 6c1dc54a..0a762876 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift @@ -23,7 +23,7 @@ struct HealthDataDetail: Codable { let minHeartRate: Double? let calories: Double let zones: HealthZonesDto - let maxHeartRateConfig: Int? + let maxHeartRateConfig: Double? let heartRateSamples: [HeartRateSampleDto]? } diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift index 30904a6a..1db1f1f7 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift @@ -10,6 +10,12 @@ import Foundation // MARK: - HealthSummaryResponseDto struct HealthSummaryResponseDto: Codable { + let summary: HealthSummaryDetail +} + +// MARK: - HealthSummaryDetail + +struct HealthSummaryDetail: Codable { let totalRecords: Int let recordsWithHealth: Int let avgHeartRate: Double? From 259ecf7d77c5917928c19435bed5edd6258093e4 Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Mon, 23 Feb 2026 21:32:42 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20.githooks=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From b510c5f199a5d3b5602c27eeb03df3cebdaf24cf Mon Sep 17 00:00:00 2001 From: LeeMyeongJin Date: Mon, 23 Feb 2026 21:36:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?style:=20=ED=8C=8C=EC=9D=BC=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=9E=91=EC=84=B1=EC=9E=90=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created by Runnect → Created by 이명진 일괄 변경 - Watch App + iOS 공통 20개 파일 --- .../RNWatch Watch App/Connectivity/WatchSessionManager.swift | 2 +- Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift | 2 +- Runnect-iOS/RNWatch Watch App/Models/WatchRunningData.swift | 2 +- Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift | 2 +- Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift | 2 +- Runnect-iOS/RNWatch Watch App/Utils/WatchTimeFormatter.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/CourseProgressBar.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/KilometerAlertView.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift | 2 +- Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift | 2 +- Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift | 2 +- Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift | 2 +- .../Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift | 2 +- .../Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift | 2 +- .../Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift | 2 +- .../Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift | 2 +- .../Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift | 2 +- .../Runnect-iOS/Network/Service/WatchSessionService.swift | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift b/Runnect-iOS/RNWatch Watch App/Connectivity/WatchSessionManager.swift index 2f4a28b8..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 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 86d6020c..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 diff --git a/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift b/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift index 23bf86fb..0f356c85 100644 --- a/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift +++ b/Runnect-iOS/Runnect-iOS/Global/UIComponents/HeartRateZoneBarView.swift @@ -2,7 +2,7 @@ // HeartRateZoneBarView.swift // Runnect-iOS // -// Created by Runnect on 2026/02/22. +// Created by 이명진 on 2026/02/22. // import UIKit diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift index 1c7b8e2e..8c2e5a16 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/HealthDataSaveRequestDto.swift @@ -2,7 +2,7 @@ // HealthDataSaveRequestDto.swift // Runnect-iOS // -// Created by Runnect on 2026/02/22. +// Created by 이명진 on 2026/02/22. // import Foundation diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift index 0a762876..1bd575c3 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthDataResponseDto.swift @@ -2,7 +2,7 @@ // HealthDataResponseDto.swift // Runnect-iOS // -// Created by Runnect on 2026/02/22. +// Created by 이명진 on 2026/02/22. // import Foundation diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift index 1db1f1f7..4d003353 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/HealthSummaryResponseDto.swift @@ -2,7 +2,7 @@ // HealthSummaryResponseDto.swift // Runnect-iOS // -// Created by Runnect on 2026/02/22. +// Created by 이명진 on 2026/02/22. // import Foundation diff --git a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift index 33872b20..20ad359a 100644 --- a/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift +++ b/Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/ResponseDto/RecordResponseDto.swift @@ -2,7 +2,7 @@ // RecordResponseDto.swift // Runnect-iOS // -// Created by Runnect on 2026/02/22. +// Created by 이명진 on 2026/02/22. // import Foundation diff --git a/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift b/Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift index 7c31bd24..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