Compare commits
2 Commits
ad8723b8fe
...
719181aa16
| Author | SHA1 | Date | |
|---|---|---|---|
|
719181aa16
|
|||
|
790b7075c5
|
@@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
56
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/app_logo_256.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
ios/OpenClimb/Assets.xcassets/AppLogo.imageset/app_logo_256_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||