This commit is contained in:
2025-10-10 16:32:10 -06:00
parent 719181aa16
commit 40efd6636f
9 changed files with 159 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,14 +10,10 @@ final class LiveActivityManager {
static let shared = LiveActivityManager()
private init() {}
private var currentActivity: Activity<SessionActivityAttributes>?
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
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(

View File

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

View File

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