Compare commits
2 Commits
ad8723b8fe
...
719181aa16
| Author | SHA1 | Date | |
|---|---|---|---|
|
719181aa16
|
|||
|
790b7075c5
|
@@ -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 = 20;
|
||||
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 = 20;
|
||||
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 = 20;
|
||||
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 = 20;
|
||||
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;
|
||||
|
||||
|
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"?>
|
||||
<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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 913 B |
@@ -1,18 +1,22 @@
|
||||
<?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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
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"
|
||||
stroke="#1C1C1C"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 604 B After Width: | Height: | Size: 878 B |
@@ -1,19 +1,23 @@
|
||||
<?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"/>
|
||||
|
||||
<g transform="translate(512, 512) scale(2.5)">
|
||||
<polygon points="-70,80 -20,-60 30,80"
|
||||
<!-- Transform to match Android layout exactly -->
|
||||
<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"
|
||||
stroke="#000000"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
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"
|
||||
stroke="#000000"
|
||||
stroke-width="4"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.9"/>
|
||||
</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>
|
||||
<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
@@ -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)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.background(Circle().fill(.quaternary))
|
||||
@@ -115,7 +115,7 @@ import SwiftUI
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(.quaternary))
|
||||
@@ -322,7 +322,7 @@ import SwiftUI
|
||||
// Check if main bundle contains the expected icon assets
|
||||
let expectedAssets = [
|
||||
"AppIcon",
|
||||
"MountainsIcon",
|
||||
"AppLogo",
|
||||
]
|
||||
|
||||
for asset in expectedAssets {
|
||||
@@ -376,7 +376,7 @@ import SwiftUI
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 64, height: 64)
|
||||
.background(
|
||||
@@ -385,7 +385,7 @@ import SwiftUI
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("MountainsIcon")
|
||||
Text("AppLogo")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text("In-app icon display")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -162,7 +166,7 @@ struct AppInfoSection: View {
|
||||
var body: some View {
|
||||
Section("App Information") {
|
||||
HStack {
|
||||
Image("MountainsIcon")
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
VStack(alignment: .leading) {
|
||||
@@ -236,7 +240,7 @@ struct ExportDataView: View {
|
||||
item: fileURL,
|
||||
preview: SharePreview(
|
||||
"OpenClimb Data Export",
|
||||
image: Image("MountainsIcon"))
|
||||
image: Image("AppLogo"))
|
||||
) {
|
||||
Label("Share Data", systemImage: "square.and.arrow.up")
|
||||
.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 {
|
||||
SettingsView()
|
||||
.environmentObject(ClimbingDataManager.preview)
|
||||
|
||||