diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj
index 7504792..25d8e1c 100644
--- a/ios/OpenClimb.xcodeproj/project.pbxproj
+++ b/ios/OpenClimb.xcodeproj/project.pbxproj
@@ -465,7 +465,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 18;
+ CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -485,7 +485,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -508,7 +508,7 @@
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 18;
+ CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -528,7 +528,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -592,7 +592,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 18;
+ CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -603,7 +603,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -622,7 +622,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 18;
+ CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -633,7 +633,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.3.0;
+ MARKETING_VERSION = 1.4.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate
index 3912283..c47ae7d 100644
Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/ios/OpenClimb/Info.plist b/ios/OpenClimb/Info.plist
index ce92529..b71fedf 100644
--- a/ios/OpenClimb/Info.plist
+++ b/ios/OpenClimb/Info.plist
@@ -10,5 +10,9 @@
This app needs access to your photo library to add photos to climbing problems.
NSCameraUsageDescription
This app needs access to your camera to take photos of climbing problems.
+ NSHealthShareUsageDescription
+ This app needs access to save your climbing workouts to Apple Health.
+ NSHealthUpdateUsageDescription
+ This app needs access to save your climbing workouts to Apple Health.
diff --git a/ios/OpenClimb/OpenClimb.entitlements b/ios/OpenClimb/OpenClimb.entitlements
index 2630f0c..7810965 100644
--- a/ios/OpenClimb/OpenClimb.entitlements
+++ b/ios/OpenClimb/OpenClimb.entitlements
@@ -6,5 +6,9 @@
group.com.atridad.OpenClimb
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
diff --git a/ios/OpenClimb/Services/HealthKitService.swift b/ios/OpenClimb/Services/HealthKitService.swift
new file mode 100644
index 0000000..73e991a
--- /dev/null
+++ b/ios/OpenClimb/Services/HealthKitService.swift
@@ -0,0 +1,154 @@
+import Combine
+import Foundation
+import HealthKit
+
+@MainActor
+class HealthKitService: ObservableObject {
+ static let shared = HealthKitService()
+
+ private let healthStore = HKHealthStore()
+ private var currentWorkoutStartDate: Date?
+ private var currentWorkoutSessionId: UUID?
+
+ @Published var isAuthorized = false
+ @Published var isEnabled = false
+
+ private let userDefaults = UserDefaults.standard
+ private let isEnabledKey = "healthKitEnabled"
+
+ private init() {
+ loadSettings()
+ }
+
+ func loadSettings() {
+ isEnabled = userDefaults.bool(forKey: isEnabledKey)
+
+ if HKHealthStore.isHealthDataAvailable() {
+ checkAuthorization()
+ }
+ }
+
+ func setEnabled(_ enabled: Bool) {
+ isEnabled = enabled
+ userDefaults.set(enabled, forKey: isEnabledKey)
+ }
+
+ func requestAuthorization() async throws {
+ guard HKHealthStore.isHealthDataAvailable() else {
+ throw HealthKitError.notAvailable
+ }
+
+ let workoutType = HKObjectType.workoutType()
+ let energyBurnedType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
+
+ let typesToShare: Set = [
+ workoutType,
+ energyBurnedType,
+ ]
+
+ let typesToRead: Set = [
+ workoutType
+ ]
+
+ try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
+
+ self.isAuthorized = true
+ }
+
+ private func checkAuthorization() {
+ let workoutType = HKObjectType.workoutType()
+ let status = healthStore.authorizationStatus(for: workoutType)
+
+ isAuthorized = (status == .sharingAuthorized)
+ }
+
+ func startWorkout(startDate: Date, sessionId: UUID) async throws {
+ guard isEnabled && isAuthorized else {
+ return
+ }
+
+ guard HKHealthStore.isHealthDataAvailable() else {
+ throw HealthKitError.notAvailable
+ }
+
+ currentWorkoutStartDate = startDate
+ currentWorkoutSessionId = sessionId
+ }
+
+ func endWorkout(endDate: Date) async throws {
+ guard isEnabled && isAuthorized else {
+ return
+ }
+
+ guard let startDate = currentWorkoutStartDate else {
+ return
+ }
+
+ guard HKHealthStore.isHealthDataAvailable() else {
+ throw HealthKitError.notAvailable
+ }
+
+ let duration = endDate.timeIntervalSince(startDate)
+ let calories = estimateCalories(durationInMinutes: duration / 60.0)
+
+ let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
+
+ let workout = HKWorkout(
+ activityType: .climbing,
+ start: startDate,
+ end: endDate,
+ duration: duration,
+ totalEnergyBurned: energyBurned,
+ totalDistance: nil,
+ metadata: [
+ HKMetadataKeyIndoorWorkout: true
+ ]
+ )
+
+ do {
+ try await healthStore.save(workout)
+
+ currentWorkoutStartDate = nil
+ currentWorkoutSessionId = nil
+ } catch {
+ currentWorkoutStartDate = nil
+ currentWorkoutSessionId = nil
+
+ throw HealthKitError.workoutSaveFailed
+ }
+ }
+
+ func cancelWorkout() {
+ currentWorkoutStartDate = nil
+ currentWorkoutSessionId = nil
+ }
+
+ func hasActiveWorkout() -> Bool {
+ return currentWorkoutStartDate != nil
+ }
+
+ private func estimateCalories(durationInMinutes: Double) -> Double {
+ let caloriesPerMinute = 8.0
+ return durationInMinutes * caloriesPerMinute
+ }
+}
+
+enum HealthKitError: LocalizedError {
+ case notAvailable
+ case notAuthorized
+ case workoutStartFailed
+ case workoutSaveFailed
+
+ var errorDescription: String? {
+ switch self {
+ case .notAvailable:
+ return "HealthKit is not available on this device"
+ case .notAuthorized:
+ return "HealthKit authorization not granted"
+ case .workoutStartFailed:
+ return "Failed to start HealthKit workout"
+ case .workoutSaveFailed:
+ return "Failed to save workout to HealthKit"
+ }
+ }
+}
diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
index 2e94b37..6f76ac0 100644
--- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
+++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift
@@ -1,5 +1,6 @@
import Combine
import Foundation
+import HealthKit
import SwiftUI
import UniformTypeIdentifiers
@@ -29,10 +30,9 @@ class ClimbingDataManager: ObservableObject {
private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol?
- // Sync service for automatic syncing
let syncService = SyncService()
+ let healthKitService = HealthKitService.shared
- // Published property to propagate sync state changes
@Published var isSyncing = false
private enum Keys {
@@ -336,6 +336,18 @@ class ClimbingDataManager: ObservableObject {
for: newSession, gymName: gym.name)
}
}
+
+ if healthKitService.isEnabled {
+ Task {
+ do {
+ try await healthKitService.startWorkout(
+ startDate: newSession.startTime ?? Date(),
+ sessionId: newSession.id)
+ } catch {
+ print("Failed to start HealthKit workout: \(error.localizedDescription)")
+ }
+ }
+ }
}
func endSession(_ sessionId: UUID) {
@@ -361,6 +373,17 @@ class ClimbingDataManager: ObservableObject {
Task {
await LiveActivityManager.shared.endLiveActivity()
}
+
+ if healthKitService.isEnabled {
+ Task {
+ do {
+ try await healthKitService.endWorkout(
+ endDate: completedSession.endTime ?? Date())
+ } catch {
+ print("Failed to end HealthKit workout: \(error.localizedDescription)")
+ }
+ }
+ }
}
}
diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift
index 746c972..3b39320 100644
--- a/ios/OpenClimb/Views/SettingsView.swift
+++ b/ios/OpenClimb/Views/SettingsView.swift
@@ -1,3 +1,4 @@
+import HealthKit
import SwiftUI
import UniformTypeIdentifiers
@@ -16,6 +17,9 @@ struct SettingsView: View {
SyncSection()
.environmentObject(dataManager.syncService)
+ HealthKitSection()
+ .environmentObject(dataManager.healthKitService)
+
DataManagementSection(
activeSheet: $activeSheet
)
@@ -815,6 +819,86 @@ struct ImportDataView: View {
}
}
+struct HealthKitSection: View {
+ @EnvironmentObject var healthKitService: HealthKitService
+ @State private var showingAuthorizationError = false
+ @State private var isRequestingAuthorization = false
+
+ var body: some View {
+ Section {
+ if !HKHealthStore.isHealthDataAvailable() {
+ HStack {
+ Image(systemName: "heart.slash")
+ .foregroundColor(.secondary)
+ Text("Apple Health not available")
+ .foregroundColor(.secondary)
+ }
+ } else {
+ Toggle(
+ isOn: Binding(
+ get: { healthKitService.isEnabled },
+ set: { newValue in
+ if newValue && !healthKitService.isAuthorized {
+ isRequestingAuthorization = true
+ Task {
+ do {
+ try await healthKitService.requestAuthorization()
+ await MainActor.run {
+ healthKitService.setEnabled(true)
+ isRequestingAuthorization = false
+ }
+ } catch {
+ await MainActor.run {
+ showingAuthorizationError = true
+ isRequestingAuthorization = false
+ }
+ }
+ }
+ } else if newValue {
+ healthKitService.setEnabled(true)
+ } else {
+ healthKitService.setEnabled(false)
+ }
+ }
+ )
+ ) {
+ HStack {
+ Image(systemName: "heart.fill")
+ .foregroundColor(.red)
+ Text("Apple Health Integration")
+ }
+ }
+ .disabled(isRequestingAuthorization)
+
+ if healthKitService.isEnabled {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(
+ "Climbing sessions will be recorded as workouts in Apple Health"
+ )
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ } header: {
+ Text("Health")
+ } footer: {
+ if healthKitService.isEnabled {
+ Text(
+ "Each climbing session will automatically be saved to Apple Health as a \"Climbing\" workout with the session duration."
+ )
+ }
+ }
+ .alert("Authorization Required", isPresented: $showingAuthorizationError) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(
+ "Please grant access to Apple Health in Settings to enable this feature."
+ )
+ }
+ }
+}
+
#Preview {
SettingsView()
.environmentObject(ClimbingDataManager.preview)