[iOS] 1.4.0 - Apple Fitness Integration!
This commit is contained in:
@@ -465,7 +465,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -592,7 +592,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -603,7 +603,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 19;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
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>
|
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>This app needs access to your camera to take photos of climbing problems.</string>
|
<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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>group.com.atridad.OpenClimb</string>
|
<string>group.com.atridad.OpenClimb</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</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 Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -29,10 +30,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private var liveActivityObserver: NSObjectProtocol?
|
private var liveActivityObserver: NSObjectProtocol?
|
||||||
|
|
||||||
// Sync service for automatic syncing
|
|
||||||
let syncService = SyncService()
|
let syncService = SyncService()
|
||||||
|
let healthKitService = HealthKitService.shared
|
||||||
|
|
||||||
// Published property to propagate sync state changes
|
|
||||||
@Published var isSyncing = false
|
@Published var isSyncing = false
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
@@ -336,6 +336,18 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
for: newSession, gymName: gym.name)
|
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) {
|
func endSession(_ sessionId: UUID) {
|
||||||
@@ -361,6 +373,17 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
Task {
|
Task {
|
||||||
await LiveActivityManager.shared.endLiveActivity()
|
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 SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@ struct SettingsView: View {
|
|||||||
SyncSection()
|
SyncSection()
|
||||||
.environmentObject(dataManager.syncService)
|
.environmentObject(dataManager.syncService)
|
||||||
|
|
||||||
|
HealthKitSection()
|
||||||
|
.environmentObject(dataManager.healthKitService)
|
||||||
|
|
||||||
DataManagementSection(
|
DataManagementSection(
|
||||||
activeSheet: $activeSheet
|
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 {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
Reference in New Issue
Block a user