Build 21
This commit is contained in:
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user