[iOS] 1.4.0 - Apple Fitness Integration!
This commit is contained in:
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
ios/OpenClimb/Services/HealthKitService.swift
Normal file
154
ios/OpenClimb/Services/HealthKitService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user