diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index de4be9f..dd8de9b 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,8 +465,9 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 21; DEVELOPMENT_TEAM = 4BC9Y2LL4B; + DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenClimb/Info.plist; @@ -485,6 +486,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -495,8 +497,11 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; + TVOS_DEPLOYMENT_TARGET = 18.6; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + XROS_DEPLOYMENT_TARGET = 2.6; }; name = Debug; }; @@ -508,8 +513,9 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 21; DEVELOPMENT_TEAM = 4BC9Y2LL4B; + DRIVERKIT_DEPLOYMENT_TARGET = 24.6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenClimb/Info.plist; @@ -528,6 +534,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -538,8 +545,11 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = 1; + TVOS_DEPLOYMENT_TARGET = 18.6; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + XROS_DEPLOYMENT_TARGET = 2.6; }; name = Release; }; @@ -592,7 +602,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 21; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -622,7 +632,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 20; + CURRENT_PROJECT_VERSION = 21; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 86b304d..d7dfe85 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index 8d4bedb..9730b32 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -50,6 +50,8 @@ struct ContentView: View { Task { try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds dataManager.onAppBecomeActive() + // Re-verify health integration when app becomes active + await dataManager.healthKitService.verifyAndRestoreIntegration() } } else if newPhase == .background { dataManager.onAppEnterBackground() @@ -59,6 +61,10 @@ struct ContentView: View { setupNotificationObservers() // Trigger auto-sync on app start only dataManager.syncService.triggerAutoSync(dataManager: dataManager) + // Verify and restore health integration if it was previously enabled + Task { + await dataManager.healthKitService.verifyAndRestoreIntegration() + } } .onDisappear { removeNotificationObservers() @@ -90,6 +96,8 @@ struct ContentView: View { // Small delay to ensure app is fully active try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds await dataManager.onAppBecomeActive() + // Re-verify health integration when returning from background + await dataManager.healthKitService.verifyAndRestoreIntegration() } } @@ -103,6 +111,8 @@ struct ContentView: View { Task { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds await dataManager.onAppBecomeActive() + // Ensure health integration is verified + await dataManager.healthKitService.verifyAndRestoreIntegration() } } diff --git a/ios/OpenClimb/Models/ActivityAttributes.swift b/ios/OpenClimb/Models/ActivityAttributes.swift index f33612f..6fefdd5 100644 --- a/ios/OpenClimb/Models/ActivityAttributes.swift +++ b/ios/OpenClimb/Models/ActivityAttributes.swift @@ -1,8 +1,8 @@ import ActivityKit import Foundation -struct SessionActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { +struct SessionActivityAttributes: ActivityAttributes, Sendable { + public struct ContentState: Codable, Hashable, Sendable { var elapsed: TimeInterval var totalAttempts: Int var completedProblems: Int @@ -17,4 +17,3 @@ extension SessionActivityAttributes { SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) } } - \ No newline at end of file diff --git a/ios/OpenClimb/Services/HealthKitService.swift b/ios/OpenClimb/Services/HealthKitService.swift index 73e991a..1f561d8 100644 --- a/ios/OpenClimb/Services/HealthKitService.swift +++ b/ios/OpenClimb/Services/HealthKitService.swift @@ -15,9 +15,12 @@ class HealthKitService: ObservableObject { private let userDefaults = UserDefaults.standard private let isEnabledKey = "healthKitEnabled" + private let workoutStartDateKey = "healthKitWorkoutStartDate" + private let workoutSessionIdKey = "healthKitWorkoutSessionId" private init() { loadSettings() + restoreActiveWorkout() } func loadSettings() { @@ -28,6 +31,62 @@ class HealthKitService: ObservableObject { } } + /// Restore active workout state + private func restoreActiveWorkout() { + if let startDate = userDefaults.object(forKey: workoutStartDateKey) as? Date, + let sessionIdString = userDefaults.string(forKey: workoutSessionIdKey), + let sessionId = UUID(uuidString: sessionIdString) + { + currentWorkoutStartDate = startDate + currentWorkoutSessionId = sessionId + print("HealthKit: Restored active workout from \(startDate)") + } + } + + /// Persist active workout state + private func persistActiveWorkout() { + if let startDate = currentWorkoutStartDate, let sessionId = currentWorkoutSessionId { + userDefaults.set(startDate, forKey: workoutStartDateKey) + userDefaults.set(sessionId.uuidString, forKey: workoutSessionIdKey) + } else { + userDefaults.removeObject(forKey: workoutStartDateKey) + userDefaults.removeObject(forKey: workoutSessionIdKey) + } + } + + /// Verify and restore health integration + func verifyAndRestoreIntegration() async { + guard isEnabled else { return } + + guard HKHealthStore.isHealthDataAvailable() else { + print("HealthKit: Device does not support HealthKit") + return + } + + checkAuthorization() + + if !isAuthorized { + print( + "HealthKit: Integration was enabled but authorization lost, attempting to restore..." + ) + + do { + try await requestAuthorization() + print("HealthKit: Authorization restored successfully") + } catch { + print("HealthKit: Failed to restore authorization: \(error.localizedDescription)") + } + } else { + print("HealthKit: Integration verified - authorization is valid") + } + + if hasActiveWorkout() { + print( + "HealthKit: Active workout restored - started at \(currentWorkoutStartDate!)" + ) + } + } + func setEnabled(_ enabled: Bool) { isEnabled = enabled userDefaults.set(enabled, forKey: isEnabledKey) @@ -73,6 +132,8 @@ class HealthKitService: ObservableObject { currentWorkoutStartDate = startDate currentWorkoutSessionId = sessionId + persistActiveWorkout() + print("HealthKit: Started workout for session \(sessionId)") } func endWorkout(endDate: Date) async throws { @@ -93,26 +154,45 @@ class HealthKitService: ObservableObject { 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 - ] + let workoutConfiguration = HKWorkoutConfiguration() + workoutConfiguration.activityType = .climbing + workoutConfiguration.locationType = .indoor + + let builder = HKWorkoutBuilder( + healthStore: healthStore, + configuration: workoutConfiguration, + device: .local() ) do { - try await healthStore.save(workout) + try await builder.beginCollection(at: startDate) + + let energyBurnedType = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)! + let energySample = HKQuantitySample( + type: energyBurnedType, + quantity: energyBurned, + start: startDate, + end: endDate + ) + try await builder.addSamples([energySample]) + + try await builder.addMetadata([HKMetadataKeyIndoorWorkout: true]) + + try await builder.endCollection(at: endDate) + let workout = try await builder.finishWorkout() + + print( + "HealthKit: Workout saved successfully with id: \(workout?.uuid.uuidString ?? "unknown")" + ) currentWorkoutStartDate = nil currentWorkoutSessionId = nil + persistActiveWorkout() } catch { + print("HealthKit: Failed to save workout: \(error.localizedDescription)") currentWorkoutStartDate = nil currentWorkoutSessionId = nil + persistActiveWorkout() throw HealthKitError.workoutSaveFailed } @@ -121,6 +201,8 @@ class HealthKitService: ObservableObject { func cancelWorkout() { currentWorkoutStartDate = nil currentWorkoutSessionId = nil + persistActiveWorkout() + print("HealthKit: Workout cancelled") } func hasActiveWorkout() -> Bool { diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 6f76ac0..1617dd1 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -28,7 +28,7 @@ class ClimbingDataManager: ObservableObject { private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let encoder = JSONEncoder() private let decoder = JSONDecoder() - private var liveActivityObserver: NSObjectProtocol? + nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol? let syncService = SyncService() let healthKitService = HealthKitService.shared diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift index 7e6f4e3..60ad654 100644 --- a/ios/OpenClimb/ViewModels/LiveActivityManager.swift +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -10,14 +10,10 @@ final class LiveActivityManager { static let shared = LiveActivityManager() private init() {} - private var currentActivity: Activity? + nonisolated(unsafe) private var currentActivity: Activity? private var healthCheckTimer: Timer? private var lastHealthCheck: Date = Date() - deinit { - healthCheckTimer?.invalidate() - } - /// Check if there's an active session and restart Live Activity if needed func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { // If we have an active session but no Live Activity, restart it @@ -130,7 +126,8 @@ final class LiveActivityManager { completedProblems: completedProblems ) - await currentActivity.update(.init(state: updatedContentState, staleDate: nil)) + nonisolated(unsafe) let activity = currentActivity + await activity.update(.init(state: updatedContentState, staleDate: nil)) } /// Call this when a ClimbSession ends to end the Live Activity @@ -141,7 +138,8 @@ final class LiveActivityManager { // First end the tracked activity if it exists if let currentActivity { print("Ending tracked Live Activity: \(currentActivity.id)") - await currentActivity.end(nil, dismissalPolicy: .immediate) + nonisolated(unsafe) let activity = currentActivity + await activity.end(nil, dismissalPolicy: .immediate) self.currentActivity = nil print("Tracked Live Activity ended successfully") } @@ -200,7 +198,7 @@ final class LiveActivityManager { print("🩺 Starting Live Activity health checks") healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - Task { @MainActor in + Task { @MainActor [weak self] in await self?.performHealthCheck() } } @@ -257,7 +255,7 @@ final class LiveActivityManager { { guard currentActivity != nil else { return } - Task { + Task { @MainActor in while currentActivity != nil { let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) await updateLiveActivity( diff --git a/ios/OpenClimb/Views/ProblemsView.swift b/ios/OpenClimb/Views/ProblemsView.swift index 59b41a6..9d2b573 100644 --- a/ios/OpenClimb/Views/ProblemsView.swift +++ b/ios/OpenClimb/Views/ProblemsView.swift @@ -32,9 +32,19 @@ struct ProblemsView: View { filtered = filtered.filter { $0.gymId == gym.id } } - // Separate active and inactive problems - let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt } - let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt } + // Separate active and inactive problems with stable sorting + let active = filtered.filter { $0.isActive }.sorted { + if $0.updatedAt == $1.updatedAt { + return $0.id.uuidString < $1.id.uuidString // Stable fallback + } + return $0.updatedAt > $1.updatedAt + } + let inactive = filtered.filter { !$0.isActive }.sorted { + if $0.updatedAt == $1.updatedAt { + return $0.id.uuidString < $1.id.uuidString // Stable fallback + } + return $0.updatedAt > $1.updatedAt + } return active + inactive } @@ -261,7 +271,7 @@ struct ProblemsList: View { @State private var problemToEdit: Problem? var body: some View { - List(problems) { problem in + List(problems, id: \.id) { problem in NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { ProblemRow(problem: problem) } @@ -273,8 +283,12 @@ struct ProblemsList: View { } Button { - let updatedProblem = problem.updated(isActive: !problem.isActive) - dataManager.updateProblem(updatedProblem) + // Use a spring animation for more natural movement + withAnimation(.spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1)) + { + let updatedProblem = problem.updated(isActive: !problem.isActive) + dataManager.updateProblem(updatedProblem) + } } label: { Label( problem.isActive ? "Mark as Reset" : "Mark as Active", @@ -293,6 +307,14 @@ struct ProblemsList: View { .tint(.blue) } } + .animation( + .spring(response: 0.5, dampingFraction: 0.8, blendDuration: 0.1), + value: problems.map { "\($0.id):\($0.isActive)" }.joined() + ) + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollIndicators(.hidden) + .clipped() .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) { Button("Cancel", role: .cancel) { problemToDelete = nil diff --git a/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift index 3725c0a..7fd2326 100644 --- a/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift +++ b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift @@ -5,8 +5,8 @@ import ActivityKit import SwiftUI import WidgetKit -struct SessionActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { +struct SessionActivityAttributes: ActivityAttributes, Sendable { + public struct ContentState: Codable, Hashable, Sendable { var elapsed: TimeInterval var totalAttempts: Int var completedProblems: Int