[iOS] 1.4.0 - Apple Fitness Integration!

This commit is contained in:
2025-10-10 11:44:33 -06:00
parent ad8723b8fe
commit 790b7075c5
7 changed files with 279 additions and 10 deletions

View File

@@ -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;

View File

@@ -10,5 +10,9 @@
<string>This app needs access to your photo library to add photos to climbing problems.</string>
<key>NSCameraUsageDescription</key>
<string>This app needs access to your camera to take photos of climbing problems.</string>
<key>NSHealthShareUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app needs access to save your climbing workouts to Apple Health.</string>
</dict>
</plist>

View File

@@ -6,5 +6,9 @@
<array>
<string>group.com.atridad.OpenClimb</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View File

@@ -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<HKSampleType> = [
workoutType,
energyBurnedType,
]
let typesToRead: Set<HKObjectType> = [
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"
}
}
}

View File

@@ -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)")
}
}
}
}
}

View File

@@ -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)