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
18 changes: 17 additions & 1 deletion Bedtime/Bedtime/BedtimeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}
}()

Expand Down
54 changes: 33 additions & 21 deletions Bedtime/Bedtime/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -128,9 +139,11 @@ struct ContentView: View {
)
}
}
.task {
try? await healthKitManager.fetchSleepData()
}
}

private func seedDefaultPreferencesIfNeeded() {
guard preferences.isEmpty else { return }
modelContext.insert(UserPreferences())
}
}

Expand Down Expand Up @@ -158,4 +171,3 @@ private extension View {
}
}
}

131 changes: 75 additions & 56 deletions Bedtime/Bedtime/Models/HealthKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class HealthKitManager: ObservableObject {
self?.reprocessStoredSamples()
}
.store(in: &cancellables)

Task { @MainActor in
try? await self.fetchSleepData()
}
}

private func checkHealthKitAvailability() throws {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Void, Error>) 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<Void, Never>) 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() {
Expand Down
2 changes: 1 addition & 1 deletion Bedtime/Bedtime/Views/LastNightCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down