Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Bedtime/Bedtime/BedtimeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,24 @@ 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.
if let storeURL = modelConfiguration.url as 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)")
}
}
}()

Expand Down
23 changes: 23 additions & 0 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@Query private var preferences: [UserPreferences]
@StateObject private var sourcePreferences: SourcePreferences
@StateObject private var healthKitManager: HealthKitManager
Expand Down Expand Up @@ -57,6 +58,17 @@ struct ContentView: View {
)
}

private var sleepBankInsight: SleepBankInsight? {
SleepInsightsEngine.generateInsight(
sleepSessions: healthKitManager.sleepSessions,
goalHours: userPreferences.sleepGoalHours,
maxSleepHours: SleepWindow.maxSleepHours(
earliestBedtime: userPreferences.earliestReasonableBedtime,
wakeTime: userPreferences.wakeTime
)
)
}

var body: some View {
let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18
NavigationStack {
Expand All @@ -80,6 +92,10 @@ struct ContentView: View {

SleepBankCard(sleepBank: sleepBank)

if let sleepBankInsight {
SleepInsightsCard(insight: sleepBankInsight)
}

if isBeforeEvening {
BedtimeRecommendationCard(recommendation: bedtimeRecommendation)
} else {
Expand Down Expand Up @@ -131,6 +147,13 @@ struct ContentView: View {
.task {
try? await healthKitManager.fetchSleepData()
}
.onChange(of: scenePhase) { _, newPhase in
// Refresh on return from background: the observer query's background
// delivery is throttled, and `.task` doesn't re-run on resume, so this
// covers data added in the Health app while we were suspended.
guard newPhase == .active else { return }
Task { try? await healthKitManager.fetchSleepData() }
}
}
}

Expand Down
61 changes: 61 additions & 0 deletions Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class HealthKitManager: ObservableObject {
private let sourcePreferences: SourcePreferences
private var rawSleepSamples: [HKCategorySample] = []
private var cancellables = Set<AnyCancellable>()
/// Long-lived query that re-loads data whenever HealthKit sleep samples change.
/// Registered once, after the first successful fetch; torn down in `deinit`.
private var observerQuery: HKObserverQuery?

@Published private(set) var permissionsRequestState: PermissionsRequestState = .loading
@Published var sleepSessions: [Date: [SleepSession]] = [:]
Expand All @@ -56,6 +59,12 @@ class HealthKitManager: ObservableObject {
.store(in: &cancellables)
}

deinit {
if let observerQuery {
healthStore.stop(observerQuery)
}
}

private func checkHealthKitAvailability() throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw NSError(domain: "HealthKitManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "HealthKit is not available on this device"])
Expand Down Expand Up @@ -90,6 +99,7 @@ class HealthKitManager: ObservableObject {
do {
try await requestAuthorization()
try await loadSleepData()
startObservingSleepChanges()
} catch {
errorMessage = error.localizedDescription
throw error
Expand All @@ -100,6 +110,57 @@ class HealthKitManager: ObservableObject {
_ = try await [fetchSleepDataForDisplay(), discoverAvailableSources()]
}

/// Registers an `HKObserverQuery` so the app re-loads sleep data whenever
/// HealthKit gains new samples — no manual pull-to-refresh needed — and enables
/// background delivery so those updates arrive even while the app is suspended.
/// Idempotent: safe to call from every `fetchSleepData()`; only registers once.
private func startObservingSleepChanges() {
guard observerQuery == nil else { return }

let query = HKObserverQuery(
sampleType: HKCategoryType.sleepAnalysis,
predicate: nil
) { [weak self] _, completionHandler, error in
// HealthKit invokes this handler off the main thread, so hop to the main
// actor to touch `errorMessage`/`loadSleepData`. Always call
// `completionHandler()` so HealthKit releases its background assertion and
// stops retrying the notification.
Task { @MainActor in
defer { completionHandler() }
guard let self else { return }
if let error {
self.errorMessage = "Sleep data observer error: \(error.localizedDescription)"
return
}
do {
try await self.loadSleepData()
} catch {
self.errorMessage = "Failed to refresh sleep data: \(error.localizedDescription)"
}
}
}

observerQuery = query
healthStore.execute(query)
enableBackgroundDelivery()
}

/// Asks HealthKit to wake the app (subject to system throttling) whenever new
/// sleep samples land, so the observer query fires while backgrounded. Requires
/// the `com.apple.developer.healthkit.background-delivery` entitlement.
private func enableBackgroundDelivery() {
healthStore.enableBackgroundDelivery(
for: HKCategoryType.sleepAnalysis,
frequency: .immediate
) { [weak self] _, error in
guard let error else { return }
let message = "Failed to enable background updates: \(error.localizedDescription)"
Task { @MainActor [weak self] in
self?.errorMessage = message
}
}
}

private func fetchSleepDataForDisplay() async throws {
let calendar = Calendar.current
let endDate = Date()
Expand Down
4 changes: 2 additions & 2 deletions Bedtime/Bedtime/Models/SleepData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ extension HKCategoryValueSleepAnalysis {
}
}

struct SleepBank {
struct SleepBank: Equatable {
let currentBalance: Double // in hours
let goalHours: Double
let averageHours: Double?
Expand Down Expand Up @@ -118,5 +118,5 @@ struct BedtimeRecommendation {
let recommendedBedtime: Date
let wakeTime: Date
let targetSleepDuration: Double // in hours
let reason: String
let reason: String?
}
Loading