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_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 = 20; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = OpenClimb/Info.plist;
@@ -485,6 +486,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.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)";
@@ -495,8 +497,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
}; };
name = Debug; name = Debug;
}; };
@@ -508,8 +513,9 @@
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 = 20; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = OpenClimb/Info.plist; INFOPLIST_FILE = OpenClimb/Info.plist;
@@ -528,6 +534,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 1.4.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)";
@@ -538,8 +545,11 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
TVOS_DEPLOYMENT_TARGET = 18.6;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
XROS_DEPLOYMENT_TARGET = 2.6;
}; };
name = Release; name = Release;
}; };
@@ -592,7 +602,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 = 20; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -622,7 +632,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 = 20; CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;

View File

@@ -50,6 +50,8 @@ struct ContentView: View {
Task { Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
// Re-verify health integration when app becomes active
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} else if newPhase == .background { } else if newPhase == .background {
dataManager.onAppEnterBackground() dataManager.onAppEnterBackground()
@@ -59,6 +61,10 @@ struct ContentView: View {
setupNotificationObservers() setupNotificationObservers()
// Trigger auto-sync on app start only // Trigger auto-sync on app start only
dataManager.syncService.triggerAutoSync(dataManager: dataManager) dataManager.syncService.triggerAutoSync(dataManager: dataManager)
// Verify and restore health integration if it was previously enabled
Task {
await dataManager.healthKitService.verifyAndRestoreIntegration()
}
} }
.onDisappear { .onDisappear {
removeNotificationObservers() removeNotificationObservers()
@@ -90,6 +96,8 @@ struct ContentView: View {
// Small delay to ensure app is fully active // Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }
@@ -103,6 +111,8 @@ struct ContentView: View {
Task { Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive() await dataManager.onAppBecomeActive()
// Ensure health integration is verified
await dataManager.healthKitService.verifyAndRestoreIntegration()
} }
} }

View File

@@ -1,8 +1,8 @@
import ActivityKit import ActivityKit
import Foundation import Foundation
struct SessionActivityAttributes: ActivityAttributes { struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable { public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval var elapsed: TimeInterval
var totalAttempts: Int var totalAttempts: Int
var completedProblems: Int var completedProblems: Int
@@ -17,4 +17,3 @@ extension SessionActivityAttributes {
SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date())
} }
} }

View File

@@ -15,9 +15,12 @@ class HealthKitService: ObservableObject {
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let isEnabledKey = "healthKitEnabled" private let isEnabledKey = "healthKitEnabled"
private let workoutStartDateKey = "healthKitWorkoutStartDate"
private let workoutSessionIdKey = "healthKitWorkoutSessionId"
private init() { private init() {
loadSettings() loadSettings()
restoreActiveWorkout()
} }
func loadSettings() { 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) { func setEnabled(_ enabled: Bool) {
isEnabled = enabled isEnabled = enabled
userDefaults.set(enabled, forKey: isEnabledKey) userDefaults.set(enabled, forKey: isEnabledKey)
@@ -73,6 +132,8 @@ class HealthKitService: ObservableObject {
currentWorkoutStartDate = startDate currentWorkoutStartDate = startDate
currentWorkoutSessionId = sessionId currentWorkoutSessionId = sessionId
persistActiveWorkout()
print("HealthKit: Started workout for session \(sessionId)")
} }
func endWorkout(endDate: Date) async throws { func endWorkout(endDate: Date) async throws {
@@ -93,26 +154,45 @@ class HealthKitService: ObservableObject {
let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories) let energyBurned = HKQuantity(unit: .kilocalorie(), doubleValue: calories)
let workout = HKWorkout( let workoutConfiguration = HKWorkoutConfiguration()
activityType: .climbing, workoutConfiguration.activityType = .climbing
start: startDate, workoutConfiguration.locationType = .indoor
end: endDate,
duration: duration, let builder = HKWorkoutBuilder(
totalEnergyBurned: energyBurned, healthStore: healthStore,
totalDistance: nil, configuration: workoutConfiguration,
metadata: [ device: .local()
HKMetadataKeyIndoorWorkout: true
]
) )
do { 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 currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout()
} catch { } catch {
print("HealthKit: Failed to save workout: \(error.localizedDescription)")
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout()
throw HealthKitError.workoutSaveFailed throw HealthKitError.workoutSaveFailed
} }
@@ -121,6 +201,8 @@ class HealthKitService: ObservableObject {
func cancelWorkout() { func cancelWorkout() {
currentWorkoutStartDate = nil currentWorkoutStartDate = nil
currentWorkoutSessionId = nil currentWorkoutSessionId = nil
persistActiveWorkout()
print("HealthKit: Workout cancelled")
} }
func hasActiveWorkout() -> Bool { func hasActiveWorkout() -> Bool {

View File

@@ -28,7 +28,7 @@ class ClimbingDataManager: ObservableObject {
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var liveActivityObserver: NSObjectProtocol? nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
let syncService = SyncService() let syncService = SyncService()
let healthKitService = HealthKitService.shared let healthKitService = HealthKitService.shared

View File

@@ -10,14 +10,10 @@ final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private init() {} private init() {}
private var currentActivity: Activity<SessionActivityAttributes>? nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer? private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date() private var lastHealthCheck: Date = Date()
deinit {
healthCheckTimer?.invalidate()
}
/// Check if there's an active session and restart Live Activity if needed /// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
// If we have an active session but no Live Activity, restart it // If we have an active session but no Live Activity, restart it
@@ -130,7 +126,8 @@ final class LiveActivityManager {
completedProblems: completedProblems 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 /// 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 // First end the tracked activity if it exists
if let currentActivity { if let currentActivity {
print("Ending tracked Live Activity: \(currentActivity.id)") 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 self.currentActivity = nil
print("Tracked Live Activity ended successfully") print("Tracked Live Activity ended successfully")
} }
@@ -200,7 +198,7 @@ final class LiveActivityManager {
print("🩺 Starting Live Activity health checks") print("🩺 Starting Live Activity health checks")
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in [weak self] _ in
Task { @MainActor in Task { @MainActor [weak self] in
await self?.performHealthCheck() await self?.performHealthCheck()
} }
} }
@@ -257,7 +255,7 @@ final class LiveActivityManager {
{ {
guard currentActivity != nil else { return } guard currentActivity != nil else { return }
Task { Task { @MainActor in
while currentActivity != nil { while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity( await updateLiveActivity(

View File

@@ -32,9 +32,19 @@ struct ProblemsView: View {
filtered = filtered.filter { $0.gymId == gym.id } filtered = filtered.filter { $0.gymId == gym.id }
} }
// Separate active and inactive problems // Separate active and inactive problems with stable sorting
let active = filtered.filter { $0.isActive }.sorted { $0.updatedAt > $1.updatedAt } let active = filtered.filter { $0.isActive }.sorted {
let inactive = filtered.filter { !$0.isActive }.sorted { $0.updatedAt > $1.updatedAt } 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 return active + inactive
} }
@@ -261,7 +271,7 @@ struct ProblemsList: View {
@State private var problemToEdit: Problem? @State private var problemToEdit: Problem?
var body: some View { var body: some View {
List(problems) { problem in List(problems, id: \.id) { problem in
NavigationLink(destination: ProblemDetailView(problemId: problem.id)) { NavigationLink(destination: ProblemDetailView(problemId: problem.id)) {
ProblemRow(problem: problem) ProblemRow(problem: problem)
} }
@@ -273,8 +283,12 @@ struct ProblemsList: View {
} }
Button { Button {
// 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) let updatedProblem = problem.updated(isActive: !problem.isActive)
dataManager.updateProblem(updatedProblem) dataManager.updateProblem(updatedProblem)
}
} label: { } label: {
Label( Label(
problem.isActive ? "Mark as Reset" : "Mark as Active", problem.isActive ? "Mark as Reset" : "Mark as Active",
@@ -293,6 +307,14 @@ struct ProblemsList: View {
.tint(.blue) .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)) { .alert("Delete Problem", isPresented: .constant(problemToDelete != nil)) {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
problemToDelete = nil problemToDelete = nil

View File

@@ -5,8 +5,8 @@ import ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
struct SessionActivityAttributes: ActivityAttributes { struct SessionActivityAttributes: ActivityAttributes, Sendable {
public struct ContentState: Codable, Hashable { public struct ContentState: Codable, Hashable, Sendable {
var elapsed: TimeInterval var elapsed: TimeInterval
var totalAttempts: Int var totalAttempts: Int
var completedProblems: Int var completedProblems: Int