Compare commits

...

2 Commits

Author SHA1 Message Date
719181aa16 iOS Build 20 2025-10-10 13:36:07 -06:00
790b7075c5 [iOS] 1.4.0 - Apple Fitness Integration! 2025-10-10 11:44:33 -06:00
20 changed files with 374 additions and 93 deletions

View File

@@ -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 = 20;
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 = 20;
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 = 20;
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 = 20;
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.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -1,18 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Dark background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#1A1A1A" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates with white border -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107" fill="#FFC107"
stroke="#1C1C1C" stroke="#FFFFFF"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain (red) - matches Android coordinates with white border -->
<polygon points="40,70 65,15 90,70"
fill="#F44336" fill="#F44336"
stroke="#1C1C1C" stroke="#FFFFFF"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -1,18 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- White background with rounded corners for iOS -->
<rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/> <rect width="1024" height="1024" fill="#FFFFFF" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain (yellow/amber) - matches Android coordinates -->
<polygon points="15,70 35,25 55,70"
fill="#FFC107" fill="#FFC107"
stroke="#1C1C1C" stroke="#1C1C1C"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain (red) - matches Android coordinates -->
<polygon points="40,70 65,15 90,70"
fill="#F44336" fill="#F44336"
stroke="#1C1C1C" stroke="#1C1C1C"
stroke-width="4" stroke-width="3"
stroke-linejoin="round"/> stroke-linejoin="round"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 878 B

View File

@@ -1,19 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://schemas.android.com/2000/svg">
<!-- Transparent background with rounded corners for iOS tinted mode -->
<rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/> <rect width="1024" height="1024" fill="transparent" rx="180" ry="180"/>
<g transform="translate(512, 512) scale(2.5)"> <!-- Transform to match Android layout exactly -->
<polygon points="-70,80 -20,-60 30,80" <g transform="translate(512, 512) scale(4.75) translate(-54, -42.5)">
<!-- Left mountain - matches Android coordinates, black fill for tinting -->
<polygon points="15,70 35,25 55,70"
fill="#000000" fill="#000000"
stroke="#000000" stroke="#000000"
stroke-width="4" stroke-width="3"
stroke-linejoin="round" stroke-linejoin="round"
opacity="0.8"/> opacity="0.8"/>
<polygon points="0,80 50,-80 100,80" <!-- Right mountain - matches Android coordinates, black fill for tinting -->
<polygon points="40,70 65,15 90,70"
fill="#000000" fill="#000000"
stroke="#000000" stroke="#000000"
stroke-width="4" stroke-width="3"
stroke-linejoin="round" stroke-linejoin="round"
opacity="0.9"/> opacity="0.9"/>
</g> </g>

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 981 B

View File

@@ -0,0 +1,56 @@
{
"images": [
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "1x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "1x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "2x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "2x"
},
{
"filename": "app_logo_256.png",
"idiom": "universal",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "app_logo_256_dark.png",
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,56 +0,0 @@
{
"images" : [
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "mountains_icon_256.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "mountains_icon_256_dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

View File

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

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

@@ -106,7 +106,7 @@ import SwiftUI
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.background(Circle().fill(.quaternary)) .background(Circle().fill(.quaternary))
@@ -115,7 +115,7 @@ import SwiftUI
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.background(Circle().fill(.quaternary)) .background(Circle().fill(.quaternary))
@@ -322,7 +322,7 @@ import SwiftUI
// Check if main bundle contains the expected icon assets // Check if main bundle contains the expected icon assets
let expectedAssets = [ let expectedAssets = [
"AppIcon", "AppIcon",
"MountainsIcon", "AppLogo",
] ]
for asset in expectedAssets { for asset in expectedAssets {
@@ -376,7 +376,7 @@ import SwiftUI
.font(.headline) .font(.headline)
HStack(spacing: 20) { HStack(spacing: 20) {
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 64, height: 64) .frame(width: 64, height: 64)
.background( .background(
@@ -385,7 +385,7 @@ import SwiftUI
) )
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("MountainsIcon") Text("AppLogo")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
Text("In-app icon display") Text("In-app icon display")

View File

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

View File

@@ -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
) )
@@ -162,7 +166,7 @@ struct AppInfoSection: View {
var body: some View { var body: some View {
Section("App Information") { Section("App Information") {
HStack { HStack {
Image("MountainsIcon") Image("AppLogo")
.resizable() .resizable()
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -236,7 +240,7 @@ struct ExportDataView: View {
item: fileURL, item: fileURL,
preview: SharePreview( preview: SharePreview(
"OpenClimb Data Export", "OpenClimb Data Export",
image: Image("MountainsIcon")) image: Image("AppLogo"))
) { ) {
Label("Share Data", systemImage: "square.and.arrow.up") Label("Share Data", systemImage: "square.and.arrow.up")
.font(.headline) .font(.headline)
@@ -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)