From 0e1c7601a4eae34cb49662b97330c75bf0e85488 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 18:20:41 +0000 Subject: [PATCH 1/2] Fix launch crashes on fresh install and after schema upgrades - Stop inserting UserPreferences during view body evaluation; seed defaults in a task and gate the main UI on the persisted @Query result instead. - Recover from incompatible SwiftData stores by deleting stale files and retrying container creation (covers upgrades where delete/reinstall may still restore an old store via iCloud backup). - Guard LastNightCard against an empty session array. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 18 +++++++- Bedtime/Bedtime/ContentView.swift | 54 ++++++++++++++--------- Bedtime/Bedtime/Views/LastNightCard.swift | 2 +- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 5288669..6658277 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -20,7 +20,23 @@ struct BedtimeApp: App { do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { - fatalError("Could not create ModelContainer: \(error)") + // The store on disk can be incompatible with the current schema after a + // model change that SwiftData can't lightweight-migrate (e.g. the max-hours + // → earliestReasonableBedtime refactor). Rather than crash on open for + // anyone upgrading, discard the stale store and rebuild it. UserPreferences + // only holds user settings, which fall back to sensible defaults. + let storeURL = modelConfiguration.url + let fileManager = FileManager.default + for suffix in ["", "-shm", "-wal"] { + let url = URL(fileURLWithPath: storeURL.path + suffix) + try? fileManager.removeItem(at: url) + } + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer after resetting the store: \(error)") + } } }() diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 24fba95..9273f05 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -30,35 +30,46 @@ struct ContentView: View { return healthKitManager.sleepSessions[lastNight] } - private var userPreferences: UserPreferences { - if let existing = preferences.first { - return existing - } else { - let new = UserPreferences() - modelContext.insert(new) - return new - } - } - - private var sleepBank: SleepBank { + private func sleepBank(for preferences: UserPreferences) -> SleepBank { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, - goalHours: userPreferences.sleepGoalHours, - recentDays: userPreferences.sleepBankDays + goalHours: preferences.sleepGoalHours, + recentDays: preferences.sleepBankDays ) } - private var bedtimeRecommendation: BedtimeRecommendation { + private func bedtimeRecommendation( + for preferences: UserPreferences, + sleepBank: SleepBank + ) -> BedtimeRecommendation { ViewModel.generateBedtimeRecommendation( - wakeTime: userPreferences.wakeTime, - earliestBedtime: userPreferences.earliestReasonableBedtime, - sleepGoal: userPreferences.sleepGoalHours, + wakeTime: preferences.wakeTime, + earliestBedtime: preferences.earliestReasonableBedtime, + sleepGoal: preferences.sleepGoalHours, sleepBank: sleepBank ) } var body: some View { + Group { + if let userPreferences = preferences.first { + mainContent(userPreferences: userPreferences) + } else { + ProgressView() + } + } + .task { + seedDefaultPreferencesIfNeeded() + try? await healthKitManager.fetchSleepData() + } + } + + @ViewBuilder + private func mainContent(userPreferences: UserPreferences) -> some View { let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18 + let sleepBank = sleepBank(for: userPreferences) + let bedtimeRecommendation = bedtimeRecommendation(for: userPreferences, sleepBank: sleepBank) + NavigationStack { ScrollView { VStack(spacing: 20) { @@ -128,9 +139,11 @@ struct ContentView: View { ) } } - .task { - try? await healthKitManager.fetchSleepData() - } + } + + private func seedDefaultPreferencesIfNeeded() { + guard preferences.isEmpty else { return } + modelContext.insert(UserPreferences()) } } @@ -158,4 +171,3 @@ private extension View { } } } - diff --git a/Bedtime/Bedtime/Views/LastNightCard.swift b/Bedtime/Bedtime/Views/LastNightCard.swift index a1148d7..a64cd31 100644 --- a/Bedtime/Bedtime/Views/LastNightCard.swift +++ b/Bedtime/Bedtime/Views/LastNightCard.swift @@ -30,7 +30,7 @@ struct LastNightCard: View { title: "Last Night" ) - if let sleepSessions { + if let sleepSessions, !sleepSessions.isEmpty { HStack { VStack(alignment: .leading) { Text("In bed at") From e121571c9fedf20755d70989e0c476f9e8593451 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 18:24:17 +0000 Subject: [PATCH 2/2] Fix infinite loading spinner and await HealthKit queries - Start the initial HealthKit fetch from HealthKitManager.init so it is not cancelled when ContentView switches from the preferences seeding state to the main UI (which left permissionsRequestState stuck at .loading). - Await HKSampleQuery results before returning from fetchSleepData so sleep data is populated before the UI leaves the loading state. - Add resumeLoadingIfNeeded() as a safety net on the main content view. Co-authored-by: Greg --- Bedtime/Bedtime/ContentView.swift | 20 +-- Bedtime/Bedtime/Models/HealthKitManager.swift | 131 ++++++++++-------- 2 files changed, 85 insertions(+), 66 deletions(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 9273f05..5980f4e 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -51,16 +51,16 @@ struct ContentView: View { } var body: some View { - Group { - if let userPreferences = preferences.first { - mainContent(userPreferences: userPreferences) - } else { - ProgressView() - } - } - .task { - seedDefaultPreferencesIfNeeded() - try? await healthKitManager.fetchSleepData() + if let userPreferences = preferences.first { + mainContent(userPreferences: userPreferences) + .task { + await healthKitManager.resumeLoadingIfNeeded() + } + } else { + ProgressView() + .task { + seedDefaultPreferencesIfNeeded() + } } } diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 489b5ca..8004080 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -54,6 +54,10 @@ class HealthKitManager: ObservableObject { self?.reprocessStoredSamples() } .store(in: &cancellables) + + Task { @MainActor in + try? await self.fetchSleepData() + } } private func checkHealthKitAvailability() throws { @@ -90,14 +94,23 @@ class HealthKitManager: ObservableObject { do { try await requestAuthorization() try await loadSleepData() + } catch is CancellationError { + throw CancellationError() } catch { errorMessage = error.localizedDescription throw error } } + + /// Safety net if an earlier fetch was cancelled before updating state. + func resumeLoadingIfNeeded() async { + guard permissionsRequestState == .loading else { return } + try? await fetchSleepData() + } private func loadSleepData() async throws { - _ = try await [fetchSleepDataForDisplay(), discoverAvailableSources()] + try await fetchSleepDataForDisplay() + await discoverAvailableSources() } private func fetchSleepDataForDisplay() async throws { @@ -113,69 +126,75 @@ class HealthKitManager: ObservableObject { let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictStartDate) - let query = HKSampleQuery( - sampleType: HKCategoryType.sleepAnalysis, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] - ) { [weak self] _, samples, error in - DispatchQueue.main.async { - if let error = error { - self?.errorMessage = "Failed to fetch sleep data: \(error.localizedDescription)" - return - } - - guard let samples = samples as? [HKCategorySample] else { - self?.errorMessage = "No sleep data found" - return + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let query = HKSampleQuery( + sampleType: HKCategoryType.sleepAnalysis, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + ) { [weak self] _, samples, error in + DispatchQueue.main.async { + if let error = error { + self?.errorMessage = "Failed to fetch sleep data: \(error.localizedDescription)" + continuation.resume(throwing: error) + return + } + + guard let samples = samples as? [HKCategorySample] else { + self?.errorMessage = "No sleep data found" + continuation.resume() + return + } + + self?.rawSleepSamples = samples + self?.processSleepSamples(samples) + continuation.resume() } - - self?.rawSleepSamples = samples - self?.processSleepSamples(samples) } + + healthStore.execute(query) } - - healthStore.execute(query) } - private func discoverAvailableSources() async throws { - // Query all time to discover all sources that have ever provided sleep data - // Use a very old start date to get all historical data - let predicate = HKQuery.predicateForSamples( - withStart: Date.distantPast, - end: Date(), - options: .strictStartDate - ) - - let query = HKSampleQuery( - sampleType: HKCategoryType.sleepAnalysis, - predicate: predicate, - limit: HKObjectQueryNoLimit, - sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] - ) { [weak self] _, samples, error in - DispatchQueue.main.async { - if let error = error { - // Don't fail if we can't discover sources, just log it - print("Failed to discover sources: \(error.localizedDescription)") - return - } - - guard let samples = samples as? [HKCategorySample] else { - return - } - - // Extract unique sources from all samples - let uniqueSources = Dictionary(grouping: samples) { $0.sourceRevision.source.bundleIdentifier } - .compactMap { _, samples -> HKSource? in - samples.first?.sourceRevision.source + private func discoverAvailableSources() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + // Query all time to discover all sources that have ever provided sleep data + let predicate = HKQuery.predicateForSamples( + withStart: Date.distantPast, + end: Date(), + options: .strictStartDate + ) + + let query = HKSampleQuery( + sampleType: HKCategoryType.sleepAnalysis, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + ) { [weak self] _, samples, error in + DispatchQueue.main.async { + defer { continuation.resume() } + + if let error = error { + print("Failed to discover sources: \(error.localizedDescription)") + return + } + + guard let samples = samples as? [HKCategorySample] else { + return } - .sorted { $0.name < $1.name } - - self?.availableSources = uniqueSources + + let uniqueSources = Dictionary(grouping: samples) { $0.sourceRevision.source.bundleIdentifier } + .compactMap { _, samples -> HKSource? in + samples.first?.sourceRevision.source + } + .sorted { $0.name < $1.name } + + self?.availableSources = uniqueSources + } } + + healthStore.execute(query) } - - healthStore.execute(query) } private func reprocessStoredSamples() {