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)