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..5980f4e 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 { + if let userPreferences = preferences.first { + mainContent(userPreferences: userPreferences) + .task { + await healthKitManager.resumeLoadingIfNeeded() + } + } else { + ProgressView() + .task { + seedDefaultPreferencesIfNeeded() + } + } + } + + @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/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() { 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")