Skip to content

Commit 0ce5358

Browse files
committed
feat: 서버 Health Data API 연동 및 2-step 저장 구현
- RecordRouter에 건강 데이터 API 엔드포인트 4개 추가 - POST /record 응답에서 recordId 파싱하여 2-step 저장 흐름 구현 - 409 Conflict 시 DELETE 후 재전송 retry 로직 추가 - WatchHealthSummary에 minHeartRate, heartRateSamples, zoneDurations 보강 - WorkoutManager에 개별 심박수 샘플 및 최저 심박수 포함 - ActivityRecordDetailVC에 과거 기록 건강 데이터 조회 및 표시 추가 - ActivityRecord에 optional healthData 필드 추가 (하위 호환)
1 parent 1d93e4d commit 0ce5358

12 files changed

Lines changed: 484 additions & 46 deletions

File tree

Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ final class WorkoutManager: NSObject, ObservableObject {
127127
func generateHealthSummary() -> [String: Any] {
128128
let avgHR = summaryHeartRate
129129
let maxHR = summaryMaxHeartRate
130+
let minHR = heartRateSamples.map(\.bpm).min() ?? 0
130131
let calories = summaryCalories
131132
let zones = calculateZoneDistribution()
132133

@@ -140,12 +141,28 @@ final class WorkoutManager: NSObject, ObservableObject {
140141
])
141142
}
142143

144+
// 개별 심박수 샘플을 서버 전송 형식으로 변환
145+
var sampleList: [[String: Any]] = []
146+
if let startDate = heartRateSamples.first?.date {
147+
for sample in heartRateSamples {
148+
let elapsedSeconds = Int(sample.date.timeIntervalSince(startDate))
149+
let zone = HeartRateZone.zone(for: sample.bpm, maxHeartRate: estimatedMaxHR)
150+
sampleList.append([
151+
"heartRate": round(sample.bpm * 10) / 10,
152+
"elapsedSeconds": elapsedSeconds,
153+
"zone": zone.rawValue
154+
])
155+
}
156+
}
157+
143158
return [
144159
"messageType": "healthSummary",
145160
"avgHeartRate": round(avgHR * 10) / 10,
146161
"maxHeartRate": round(maxHR * 10) / 10,
162+
"minHeartRate": round(minHR * 10) / 10,
147163
"totalCalories": round(calories * 10) / 10,
148164
"heartRateZones": zoneList,
165+
"heartRateSamples": sampleList,
149166
"timestamp": Date().timeIntervalSince1970
150167
]
151168
}

Runnect-iOS/Runnect-iOS.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@
109109
CE4545D5295D7AF5003201E1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE4545D3295D7AF5003201E1 /* LaunchScreen.storyboard */; };
110110
CE4942AD296FCD2300736701 /* UploadedCourseDetailResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE4942AC296FCD2300736701 /* UploadedCourseDetailResponseDto.swift */; };
111111
CE55BC11296D4EA600E8CD69 /* RunningRecordRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */; };
112+
F1A2B3C4D5E6F7A81234AB02 /* RecordResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */; };
113+
F1A2B3C4D5E6F7A81234AB04 /* HealthDataSaveRequestDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */; };
114+
F1A2B3C4D5E6F7A81234AB06 /* HealthDataResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */; };
115+
F1A2B3C4D5E6F7A81234AB08 /* HealthSummaryResponseDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */; };
112116
CE5645162961B72E000A2856 /* ImageLiterals.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5645152961B72E000A2856 /* ImageLiterals.swift */; };
113117
CE58759E29601476005D967E /* LoadingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE58759D29601476005D967E /* LoadingIndicator.swift */; };
114118
CE5875A029601500005D967E /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE58759F29601500005D967E /* Toast.swift */; };
@@ -299,6 +303,10 @@
299303
CE4545D6295D7AF5003201E1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
300304
CE4942AC296FCD2300736701 /* UploadedCourseDetailResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadedCourseDetailResponseDto.swift; sourceTree = "<group>"; };
301305
CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningRecordRequestDto.swift; sourceTree = "<group>"; };
306+
F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordResponseDto.swift; sourceTree = "<group>"; };
307+
F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDataSaveRequestDto.swift; sourceTree = "<group>"; };
308+
F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDataResponseDto.swift; sourceTree = "<group>"; };
309+
F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSummaryResponseDto.swift; sourceTree = "<group>"; };
302310
CE5645152961B72E000A2856 /* ImageLiterals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLiterals.swift; sourceTree = "<group>"; };
303311
CE58759D29601476005D967E /* LoadingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingIndicator.swift; sourceTree = "<group>"; };
304312
CE58759F29601500005D967E /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; };
@@ -902,6 +910,7 @@
902910
isa = PBXGroup;
903911
children = (
904912
CE55BC10296D4EA600E8CD69 /* RunningRecordRequestDto.swift */,
913+
F1A2B3C4D5E6F7A81234AB03 /* HealthDataSaveRequestDto.swift */,
905914
);
906915
path = RequestDto;
907916
sourceTree = "<group>";
@@ -910,6 +919,9 @@
910919
isa = PBXGroup;
911920
children = (
912921
CEF3CD99296DB305002723A1 /* CourseDetailResponseDto.swift */,
922+
F1A2B3C4D5E6F7A81234AB01 /* RecordResponseDto.swift */,
923+
F1A2B3C4D5E6F7A81234AB05 /* HealthDataResponseDto.swift */,
924+
F1A2B3C4D5E6F7A81234AB07 /* HealthSummaryResponseDto.swift */,
913925
);
914926
path = ResponseDto;
915927
sourceTree = "<group>";
@@ -1426,6 +1438,10 @@
14261438
files = (
14271439
715D36E82B2CC64000CAA9D6 /* MyUploadedCourseResponseDto.swift in Sources */,
14281440
CE55BC11296D4EA600E8CD69 /* RunningRecordRequestDto.swift in Sources */,
1441+
F1A2B3C4D5E6F7A81234AB02 /* RecordResponseDto.swift in Sources */,
1442+
F1A2B3C4D5E6F7A81234AB04 /* HealthDataSaveRequestDto.swift in Sources */,
1443+
F1A2B3C4D5E6F7A81234AB06 /* HealthDataResponseDto.swift in Sources */,
1444+
F1A2B3C4D5E6F7A81234AB08 /* HealthSummaryResponseDto.swift in Sources */,
14291445
A3BC2F2F2962C40A00198261 /* UploadedCourseInfoVC.swift in Sources */,
14301446
CE6655EA295D88B200C64E12 /* UITabBar+.swift in Sources */,
14311447
CE9291272965D0ED0010959C /* StatsInfoView.swift in Sources */,

Runnect-iOS/Runnect-iOS/Network/Dto/MyPageDto/ActivityRecordInfoDto.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ struct ActivityRecord: Codable {
2424
let distance: Double
2525
let time, pace: String
2626
let departure: ActivityRecordDeparture
27+
let healthData: ActivityRecordHealthData?
28+
}
29+
30+
// MARK: - ActivityRecordHealthData
31+
32+
struct ActivityRecordHealthData: Codable {
33+
let avgHeartRate: Double?
34+
let calories: Double?
2735
}
2836

2937
// MARK: - Departure
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// HealthDataSaveRequestDto.swift
3+
// Runnect-iOS
4+
//
5+
// Created by Runnect on 2026/02/22.
6+
//
7+
8+
import Foundation
9+
10+
// MARK: - HealthDataSaveRequestDto
11+
12+
struct HealthDataSaveRequestDto: Codable {
13+
let avgHeartRate: Double
14+
let maxHeartRate: Double
15+
let minHeartRate: Double?
16+
let calories: Double
17+
let zone1Seconds: Int
18+
let zone2Seconds: Int
19+
let zone3Seconds: Int
20+
let zone4Seconds: Int
21+
let zone5Seconds: Int
22+
let maxHeartRateConfig: Int?
23+
let heartRateSamples: [HeartRateSampleDto]?
24+
}
25+
26+
// MARK: - HeartRateSampleDto
27+
28+
struct HeartRateSampleDto: Codable {
29+
let heartRate: Double
30+
let elapsedSeconds: Int
31+
let zone: Int
32+
}

Runnect-iOS/Runnect-iOS/Network/Dto/RunningDto/RequestDto/RunningRecordRequestDto.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,4 @@ struct RunningRecordRequestDto: Codable {
1313
let courseId: Int
1414
let publicCourseId: Int?
1515
let title, time, pace: String
16-
let healthData: HealthDataRequestDto?
17-
}
18-
19-
// MARK: - HealthDataRequestDto
20-
21-
struct HealthDataRequestDto: Codable {
22-
let avgHeartRate: Double
23-
let maxHeartRate: Double
24-
let totalCalories: Double
2516
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// HealthDataResponseDto.swift
3+
// Runnect-iOS
4+
//
5+
// Created by Runnect on 2026/02/22.
6+
//
7+
8+
import Foundation
9+
10+
// MARK: - HealthDataResponseDto
11+
12+
struct HealthDataResponseDto: Codable {
13+
let healthData: HealthDataDetail?
14+
}
15+
16+
// MARK: - HealthDataDetail
17+
18+
struct HealthDataDetail: Codable {
19+
let id: Int
20+
let recordId: Int
21+
let avgHeartRate: Double
22+
let maxHeartRate: Double
23+
let minHeartRate: Double?
24+
let calories: Double
25+
let zones: HealthZonesDto
26+
let maxHeartRateConfig: Int?
27+
let heartRateSamples: [HeartRateSampleDto]?
28+
}
29+
30+
// MARK: - HealthZonesDto
31+
32+
struct HealthZonesDto: Codable {
33+
let zone1Seconds: Int
34+
let zone2Seconds: Int
35+
let zone3Seconds: Int
36+
let zone4Seconds: Int
37+
let zone5Seconds: Int
38+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// HealthSummaryResponseDto.swift
3+
// Runnect-iOS
4+
//
5+
// Created by Runnect on 2026/02/22.
6+
//
7+
8+
import Foundation
9+
10+
// MARK: - HealthSummaryResponseDto
11+
12+
struct HealthSummaryResponseDto: Codable {
13+
let totalRecords: Int
14+
let recordsWithHealth: Int
15+
let avgHeartRate: Double?
16+
let avgCalories: Double?
17+
let totalCalories: Double?
18+
let zoneDistribution: HealthZonesDto?
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// RecordResponseDto.swift
3+
// Runnect-iOS
4+
//
5+
// Created by Runnect on 2026/02/22.
6+
//
7+
8+
import Foundation
9+
10+
// MARK: - RecordResponseDto
11+
12+
struct RecordResponseDto: Codable {
13+
let record: RecordDetail
14+
15+
struct RecordDetail: Codable {
16+
let id: Int
17+
let createdAt: String
18+
}
19+
}

Runnect-iOS/Runnect-iOS/Network/Router/RecordRouter.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ enum RecordRouter {
1414
case getActivityRecordInfo
1515
case deleteRecord(recordIdList: [Int])
1616
case updateRecordTitle(recordId: Int, recordTitle: String)
17+
case saveHealthData(recordId: Int, param: HealthDataSaveRequestDto)
18+
case getHealthData(recordId: Int)
19+
case deleteHealthData(recordId: Int)
20+
case getHealthSummary(startDate: String, endDate: String)
1721
}
1822

1923
extension RecordRouter: TargetType {
@@ -33,19 +37,27 @@ extension RecordRouter: TargetType {
3337
return "/record/user"
3438
case .updateRecordTitle(recordId: let recordId, _):
3539
return "/record/\(recordId)"
40+
case .saveHealthData(let recordId, _),
41+
.getHealthData(let recordId),
42+
.deleteHealthData(let recordId):
43+
return "/record/\(recordId)/health"
44+
case .getHealthSummary:
45+
return "/health/summary"
3646
}
3747
}
3848

3949
var method: Moya.Method {
4050
switch self {
41-
case .recordRunning:
51+
case .recordRunning, .saveHealthData:
4252
return .post
43-
case .getActivityRecordInfo:
53+
case .getActivityRecordInfo, .getHealthData, .getHealthSummary:
4454
return .get
4555
case .deleteRecord:
4656
return .put
4757
case .updateRecordTitle:
4858
return .patch
59+
case .deleteHealthData:
60+
return .delete
4961
}
5062
}
5163

@@ -65,6 +77,19 @@ extension RecordRouter: TargetType {
6577
do {
6678
return .requestParameters(parameters: ["title": recordTitle], encoding: JSONEncoding.default)
6779
}
80+
case .saveHealthData(_, let param):
81+
do {
82+
return .requestParameters(parameters: try param.asParameter(), encoding: JSONEncoding.default)
83+
} catch {
84+
fatalError(error.localizedDescription)
85+
}
86+
case .getHealthData, .deleteHealthData:
87+
return .requestPlain
88+
case .getHealthSummary(let startDate, let endDate):
89+
return .requestParameters(
90+
parameters: ["startDate": startDate, "endDate": endDate],
91+
encoding: URLEncoding.queryString
92+
)
6893
}
6994
}
7095

Runnect-iOS/Runnect-iOS/Network/Service/WatchSessionService.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,24 @@ extension WatchSessionService: WCSessionDelegate {
194194
struct WatchHealthSummary {
195195
let avgHeartRate: Double
196196
let maxHeartRate: Double
197+
let minHeartRate: Double?
197198
let totalCalories: Double
198199
let heartRateZones: [[String: Any]]
200+
let heartRateSamples: [[String: Any]]
199201
let timestamp: Date
200202

203+
/// heartRateZones 배열에서 zone별 초(seconds) 추출
204+
var zoneDurations: [Int: Int] {
205+
var durations: [Int: Int] = [:]
206+
for zone in heartRateZones {
207+
if let zoneNum = zone["zone"] as? Int,
208+
let seconds = zone["durationSeconds"] as? Int {
209+
durations[zoneNum] = seconds
210+
}
211+
}
212+
return durations
213+
}
214+
201215
static func fromDictionary(_ dict: [String: Any]) -> WatchHealthSummary? {
202216
guard let avgHeartRate = dict["avgHeartRate"] as? Double,
203217
let maxHeartRate = dict["maxHeartRate"] as? Double,
@@ -207,24 +221,33 @@ struct WatchHealthSummary {
207221
}
208222

209223
let zones = dict["heartRateZones"] as? [[String: Any]] ?? []
224+
let minHeartRate = dict["minHeartRate"] as? Double
225+
let samples = dict["heartRateSamples"] as? [[String: Any]] ?? []
210226

211227
return WatchHealthSummary(
212228
avgHeartRate: avgHeartRate,
213229
maxHeartRate: maxHeartRate,
230+
minHeartRate: minHeartRate,
214231
totalCalories: totalCalories,
215232
heartRateZones: zones,
233+
heartRateSamples: samples,
216234
timestamp: Date(timeIntervalSince1970: timestamp)
217235
)
218236
}
219237

220238
func toDictionary() -> [String: Any] {
221-
return [
239+
var dict: [String: Any] = [
222240
"avgHeartRate": avgHeartRate,
223241
"maxHeartRate": maxHeartRate,
224242
"totalCalories": totalCalories,
225243
"heartRateZones": heartRateZones,
244+
"heartRateSamples": heartRateSamples,
226245
"timestamp": timestamp.timeIntervalSince1970
227246
]
247+
if let minHeartRate {
248+
dict["minHeartRate"] = minHeartRate
249+
}
250+
return dict
228251
}
229252
}
230253

0 commit comments

Comments
 (0)