diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 98271ac..5e4c5dd 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -41,6 +41,32 @@ struct BedtimeApp: App { } }() + init() { + Self.seedDefaultPreferencesIfNeeded(in: sharedModelContainer) + } + + /// Creates the single `UserPreferences` record when the store is empty. + /// + /// SwiftData persists the schema but never creates instances, so a fresh install — + /// or a launch right after the store reset above — starts with nothing to read. + /// Seeding here, before the view tree exists, keeps the insert out of `ContentView`'s + /// body evaluation: SwiftUI reads a view's properties an unspecified number of times + /// per update, so inserting from there can write several default records and mutates + /// persistent state in the middle of a render. + @MainActor + private static func seedDefaultPreferencesIfNeeded(in container: ModelContainer) { + let context = container.mainContext + + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 1 + + // A failed fetch is not a reason to insert; ContentView still seeds defensively. + guard let existing = try? context.fetch(descriptor), existing.isEmpty else { return } + + context.insert(UserPreferences()) + try? context.save() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 81cfafb..52053f3 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -41,17 +41,7 @@ struct ContentView: View { return ViewModel.recentSourceAppLinks(sleepSessions: healthKitManager.allSleepSessions) } - 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 userPreferences: UserPreferences) -> SleepBank { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, @@ -61,7 +51,7 @@ struct ContentView: View { /// Balance over the widest selectable lookback, independent of the current range, so the /// balance chart can show every night the range start can be moved to. - private var fullWindowSleepBank: SleepBank { + private func fullWindowSleepBank(for userPreferences: UserPreferences) -> SleepBank { ViewModel.calculateSleepBank( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, @@ -69,14 +59,17 @@ struct ContentView: View { ) } - private var sleepBankDaysBinding: Binding { + private func sleepBankDaysBinding(for userPreferences: UserPreferences) -> Binding { Binding( get: { userPreferences.sleepBankDays }, set: { userPreferences.sleepBankDays = $0 } ) } - private var bedtimeRecommendation: BedtimeRecommendation { + private func bedtimeRecommendation( + for userPreferences: UserPreferences, + sleepBank: SleepBank + ) -> BedtimeRecommendation { ViewModel.generateBedtimeRecommendation( wakeTime: userPreferences.wakeTime, earliestBedtime: userPreferences.earliestReasonableBedtime, @@ -86,14 +79,14 @@ struct ContentView: View { ) } - private var wakeTimeBinding: Binding { + private func wakeTimeBinding(for userPreferences: UserPreferences) -> Binding { Binding( get: { userPreferences.wakeTime }, set: { userPreferences.wakeTime = $0 } ) } - private var sleepBankInsight: SleepBankInsight? { + private func sleepBankInsight(for userPreferences: UserPreferences) -> SleepBankInsight? { SleepInsightsEngine.generateInsight( sleepSessions: healthKitManager.sleepSessions, goalHours: userPreferences.sleepGoalHours, @@ -104,8 +97,32 @@ struct ContentView: View { ) } + /// `BedtimeApp` seeds the record before the view tree is built, so the placeholder + /// below is only reachable if that seed failed — or in previews and tests, which + /// start from an empty in-memory container. var body: some View { + if let userPreferences = preferences.first { + dashboard(for: userPreferences) + } else { + ProgressView() + .task { seedDefaultPreferencesIfNeeded() } + } + } + + private func seedDefaultPreferencesIfNeeded() { + guard preferences.isEmpty else { return } + modelContext.insert(UserPreferences()) + } + + @ViewBuilder + private func dashboard(for userPreferences: UserPreferences) -> some View { let isBeforeEvening = Calendar.current.component(.hour, from: Date()) < 18 + let currentSleepBank = sleepBank(for: userPreferences) + let recommendation = bedtimeRecommendation(for: userPreferences, sleepBank: currentSleepBank) + let insight = sleepBankInsight(for: userPreferences) + let daysBinding = sleepBankDaysBinding(for: userPreferences) + let wakeBinding = wakeTimeBinding(for: userPreferences) + NavigationStack { ScrollView { VStack(spacing: 20) { @@ -124,20 +141,20 @@ struct ContentView: View { sourceAppLinks: recentSourceAppLinks) } else { BedtimeRecommendationCard( - recommendation: bedtimeRecommendation, - wakeTime: wakeTimeBinding + recommendation: recommendation, + wakeTime: wakeBinding ) } SleepBankCard( - sleepBank: sleepBank, - fullWindowBank: fullWindowSleepBank, - sleepBankDays: sleepBankDaysBinding + sleepBank: currentSleepBank, + fullWindowBank: fullWindowSleepBank(for: userPreferences), + sleepBankDays: daysBinding ) - if let sleepBankInsight { + if let insight { SleepInsightsCard( - insight: sleepBankInsight, + insight: insight, currentSleepBankDays: userPreferences.sleepBankDays, onApplyDays: { days in withAnimation(.easeInOut(duration: 0.2)) { @@ -149,8 +166,8 @@ struct ContentView: View { if isBeforeEvening { BedtimeRecommendationCard( - recommendation: bedtimeRecommendation, - wakeTime: wakeTimeBinding + recommendation: recommendation, + wakeTime: wakeBinding ) } else { LastNightCard(sleepSessions: lastNightData, @@ -165,7 +182,7 @@ struct ContentView: View { allSessions: healthKitManager.allSleepSessions, excludedSourceIDs: sourcePreferences.excludedBundleIdentifiers, sleepGoal: userPreferences.sleepGoalHours, - sleepBankDays: sleepBankDaysBinding + sleepBankDays: daysBinding ) } }