Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// WatchSessionManager.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import Foundation
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Models/HeartRateZone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// HeartRateZone.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/09.
// Created by 이명진 on 2026/02/09.
//

import SwiftUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// WatchRunningData.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import Foundation
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Utils/HapticManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// HapticManager.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import WatchKit
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Utils/RunnectColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// RunnectColors.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// WatchTimeFormatter.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import Foundation
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Views/ActiveRunView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// ActiveRunView.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Views/CountdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// CountdownView.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// CourseProgressBar.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/09.
// Created by 이명진 on 2026/02/09.
//

import SwiftUI
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// KilometerAlertView.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Views/RunSummaryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// RunSummaryView.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/Views/RunningIdleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// RunningIdleView.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import SwiftUI
Expand Down
2 changes: 1 addition & 1 deletion Runnect-iOS/RNWatch Watch App/WatchAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// WatchAppDelegate.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import WatchKit
Expand Down
116 changes: 115 additions & 1 deletion Runnect-iOS/RNWatch Watch App/Workout/WorkoutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// WorkoutManager.swift
// RNWatch Watch App
//
// Created by Runnect on 2026/02/08.
// Created by 이명진 on 2026/02/08.
//

import Foundation
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -97,15 +107,106 @@ 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 {
self.isWorkoutActive = false
}
}

// MARK: - Health Summary

func generateHealthSummary() -> [String: Any] {
let avgHR = summaryHeartRate
let maxHR = summaryMaxHeartRate
let minHR = heartRateSamples.map(\.bpm).min() ?? 0
let calories = summaryCalories
let zones = calculateZoneDistribution()

var zoneList: [[String: Any]] = []
for zone in zones {
zoneList.append([
"zone": zone.zone.rawValue,
"zoneName": zone.zone.name,
"durationSeconds": zone.durationSeconds,
"percentage": zone.percentage
])
}

// 개별 심박수 샘플을 서버 전송 형식으로 변환
var sampleList: [[String: Any]] = []
if let startDate = heartRateSamples.first?.date {
for sample in heartRateSamples {
let elapsedSeconds = Int(sample.date.timeIntervalSince(startDate))
let zone = HeartRateZone.zone(for: sample.bpm, maxHeartRate: estimatedMaxHR)
sampleList.append([
"heartRate": round(sample.bpm * 10) / 10,
"elapsedSeconds": elapsedSeconds,
"zone": zone.rawValue
])
}
}

return [
"messageType": "healthSummary",
"avgHeartRate": round(avgHR * 10) / 10,
"maxHeartRate": round(maxHR * 10) / 10,
"minHeartRate": round(minHR * 10) / 10,
"totalCalories": round(calories * 10) / 10,
"heartRateZones": zoneList,
"heartRateSamples": sampleList,
"timestamp": Date().timeIntervalSince1970
]
}

private func sendHealthSummaryToiPhone() {
let summary = generateHealthSummary()
WatchSessionManager.shared.sendHealthSummary(summary)
}

private func calculateZoneDistribution() -> [(zone: HeartRateZone, durationSeconds: Int, percentage: Double)] {
guard heartRateSamples.count >= 2 else { return [] }

var zoneDurations: [HeartRateZone: TimeInterval] = [:]
for zone in HeartRateZone.allCases {
zoneDurations[zone] = 0
}

for i in 0..<(heartRateSamples.count - 1) {
let sample = heartRateSamples[i]
let nextSample = heartRateSamples[i + 1]
let duration = nextSample.date.timeIntervalSince(sample.date)
let zone = HeartRateZone.zone(for: sample.bpm, maxHeartRate: estimatedMaxHR)
zoneDurations[zone, default: 0] += duration
}

if let lastSample = heartRateSamples.last {
let zone = HeartRateZone.zone(for: lastSample.bpm, maxHeartRate: estimatedMaxHR)
zoneDurations[zone, default: 0] += 5
}

let totalDuration = zoneDurations.values.reduce(0, +)
guard totalDuration > 0 else { return [] }

return HeartRateZone.allCases.compactMap { zone in
let duration = zoneDurations[zone] ?? 0
guard duration > 0 else { return nil }
return (
zone: zone,
durationSeconds: Int(duration),
percentage: round((duration / totalDuration) * 1000) / 10
)
}
}

// MARK: - Pause / Resume

func pauseWorkout() {
Expand All @@ -125,7 +226,17 @@ final class WorkoutManager: NSObject, ObservableObject {
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
if let value = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) {
self.heartRate = value
self.heartRateSamples.append((bpm: value, date: Date()))
if value > self.maxHeartRateValue {
self.maxHeartRateValue = value
}
self.updateHeartRateZone(value)

// Send realtime health data to iPhone
WatchSessionManager.shared.sendRealtimeHealth(
heartRate: value,
calories: self.activeCalories
)
}

case HKQuantityType(.activeEnergyBurned):
Expand Down Expand Up @@ -158,8 +269,11 @@ final class WorkoutManager: NSObject, ObservableObject {
activeCalories = 0
summaryHeartRate = 0
summaryCalories = 0
summaryMaxHeartRate = 0
isWorkoutActive = false
currentZone = .zone1
heartRateSamples.removeAll()
maxHeartRateValue = 0
workoutSession = nil
workoutBuilder = nil
}
Expand Down
Loading