- Fix CloudKitSyncManager deinit concurrency violation - Replace deprecated List(selection:) with VStack+ForEach sidebar - Replace removed Animation.none with Animation.linear(duration: 0) - Fix CountdownRing initializer parameter mismatch - Unwrap optional timer.duration in ShareableTimerManager and DataExportManager - Add missing .event case to exhaustive switch - Change CascadeWarning.scheduledTime from let to var - Fix CalendarSyncManager CMTimer init (add elapsedBeforePause, remove non-existent params) - Add missing UserNotifications import in SleepManager - Remove parameterized App Intents phrases (iOS 26 restriction) - Temporarily remove watchOS target dependency for iOS build
277 lines
9.6 KiB
Swift
277 lines
9.6 KiB
Swift
// ── Sleep Manager ─────────────────────────────────────────────
|
|
// Bedtime routines, smart alarm, HealthKit sleep data integration
|
|
// Ties into AI reschedule for sleep-aware morning adjustments
|
|
|
|
import Foundation
|
|
import HealthKit
|
|
import Combine
|
|
import UserNotifications
|
|
|
|
@MainActor
|
|
final class SleepManager: ObservableObject {
|
|
static let shared = SleepManager()
|
|
|
|
@Published var bedtimeRoutine: BedtimeRoutine?
|
|
@Published var smartAlarmConfig: SmartAlarmConfig?
|
|
@Published var lastSleepData: SleepSummary?
|
|
@Published var healthKitAuthorized = false
|
|
|
|
private let healthStore = HKHealthStore()
|
|
private let routineKey = "chronomind-bedtime-routine"
|
|
private let alarmKey = "chronomind-smart-alarm"
|
|
private let sleepKey = "chronomind-last-sleep"
|
|
|
|
private init() {
|
|
loadData()
|
|
}
|
|
|
|
// MARK: - HealthKit Authorization
|
|
|
|
func requestHealthKitAccess() async -> Bool {
|
|
guard HKHealthStore.isHealthDataAvailable() else { return false }
|
|
|
|
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
|
let readTypes: Set<HKObjectType> = [sleepType]
|
|
|
|
do {
|
|
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
|
|
healthKitAuthorized = true
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Sleep Data (Read-Only from HealthKit)
|
|
|
|
func fetchLastNightSleep() async -> SleepSummary? {
|
|
guard healthKitAuthorized else { return nil }
|
|
|
|
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))!
|
|
|
|
let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now, options: .strictStartDate)
|
|
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
|
|
|
return await withCheckedContinuation { continuation in
|
|
let query = HKSampleQuery(
|
|
sampleType: sleepType,
|
|
predicate: predicate,
|
|
limit: HKObjectQueryNoLimit,
|
|
sortDescriptors: [sortDescriptor]
|
|
) { _, samples, error in
|
|
guard let samples = samples as? [HKCategorySample], !samples.isEmpty else {
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
|
|
let summary = self.processSleepSamples(samples)
|
|
Task { @MainActor in
|
|
self.lastSleepData = summary
|
|
self.saveSleepData(summary)
|
|
}
|
|
continuation.resume(returning: summary)
|
|
}
|
|
healthStore.execute(query)
|
|
}
|
|
}
|
|
|
|
private func processSleepSamples(_ samples: [HKCategorySample]) -> SleepSummary {
|
|
var totalAsleep: TimeInterval = 0
|
|
var totalInBed: TimeInterval = 0
|
|
var sleepStart: Date?
|
|
var sleepEnd: Date?
|
|
|
|
for sample in samples {
|
|
let duration = sample.endDate.timeIntervalSince(sample.startDate)
|
|
|
|
switch sample.value {
|
|
case HKCategoryValueSleepAnalysis.asleepCore.rawValue,
|
|
HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
|
|
HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
|
totalAsleep += duration
|
|
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
totalInBed += duration
|
|
default:
|
|
break
|
|
}
|
|
|
|
if sleepStart == nil || sample.startDate < sleepStart! {
|
|
sleepStart = sample.startDate
|
|
}
|
|
if sleepEnd == nil || sample.endDate > sleepEnd! {
|
|
sleepEnd = sample.endDate
|
|
}
|
|
}
|
|
|
|
return SleepSummary(
|
|
totalSleepHours: totalAsleep / 3600,
|
|
totalInBedHours: max(totalInBed, totalAsleep) / 3600,
|
|
sleepEfficiency: totalInBed > 0 ? (totalAsleep / totalInBed) : 0,
|
|
bedtime: sleepStart,
|
|
wakeTime: sleepEnd,
|
|
date: Date()
|
|
)
|
|
}
|
|
|
|
// MARK: - Smart Alarm
|
|
|
|
/// Generate a reschedule suggestion based on sleep data
|
|
func sleepAwareRescheduleSuggestion(sleepSummary: SleepSummary) -> String? {
|
|
guard let wakeTime = sleepSummary.wakeTime else { return nil }
|
|
|
|
let sleepHours = sleepSummary.totalSleepHours
|
|
|
|
if sleepHours < 6 {
|
|
let deficit = Int((7 - sleepHours) * 15) // 15 min per hour of deficit
|
|
return "You slept \(String(format: "%.0f", sleepHours))h \(Int((sleepHours.truncatingRemainder(dividingBy: 1)) * 60))m. Shift your morning by \(deficit) minutes?"
|
|
} else if sleepHours < 7 {
|
|
return "You slept \(String(format: "%.0f", sleepHours))h \(Int((sleepHours.truncatingRemainder(dividingBy: 1)) * 60))m. Consider a gentler morning — shift timers by 10 minutes?"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Bedtime Routine
|
|
|
|
func saveBedtimeRoutine(_ routine: BedtimeRoutine) {
|
|
bedtimeRoutine = routine
|
|
if let data = try? JSONEncoder().encode(routine) {
|
|
UserDefaults.standard.set(data, forKey: routineKey)
|
|
}
|
|
}
|
|
|
|
func saveSmartAlarmConfig(_ config: SmartAlarmConfig) {
|
|
smartAlarmConfig = config
|
|
if let data = try? JSONEncoder().encode(config) {
|
|
UserDefaults.standard.set(data, forKey: alarmKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - Wind-Down Notifications
|
|
|
|
func scheduleWindDownNotification(bedtime: Date) {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "Time to wind down"
|
|
content.body = "Bedtime in 30 minutes — start your wind-down routine"
|
|
content.sound = .default
|
|
content.categoryIdentifier = "WIND_DOWN"
|
|
|
|
let triggerDate = bedtime.addingTimeInterval(-1800) // 30 min before
|
|
guard triggerDate > Date() else { return }
|
|
|
|
let components = Calendar.current.dateComponents([.hour, .minute], from: triggerDate)
|
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
|
let request = UNNotificationRequest(identifier: "wind-down", content: content, trigger: trigger)
|
|
|
|
UNUserNotificationCenter.current().add(request)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func loadData() {
|
|
if let data = UserDefaults.standard.data(forKey: routineKey),
|
|
let decoded = try? JSONDecoder().decode(BedtimeRoutine.self, from: data) {
|
|
bedtimeRoutine = decoded
|
|
}
|
|
if let data = UserDefaults.standard.data(forKey: alarmKey),
|
|
let decoded = try? JSONDecoder().decode(SmartAlarmConfig.self, from: data) {
|
|
smartAlarmConfig = decoded
|
|
}
|
|
if let data = UserDefaults.standard.data(forKey: sleepKey),
|
|
let decoded = try? JSONDecoder().decode(SleepSummary.self, from: data) {
|
|
lastSleepData = decoded
|
|
}
|
|
}
|
|
|
|
private func saveSleepData(_ summary: SleepSummary) {
|
|
if let data = try? JSONEncoder().encode(summary) {
|
|
UserDefaults.standard.set(data, forKey: sleepKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Models
|
|
|
|
struct SleepSummary: Codable {
|
|
let totalSleepHours: Double
|
|
let totalInBedHours: Double
|
|
let sleepEfficiency: Double // 0-1
|
|
let bedtime: Date?
|
|
let wakeTime: Date?
|
|
let date: Date
|
|
|
|
var formattedSleep: String {
|
|
let hours = Int(totalSleepHours)
|
|
let minutes = Int((totalSleepHours - Double(hours)) * 60)
|
|
return "\(hours)h \(minutes)m"
|
|
}
|
|
|
|
var qualityLabel: String {
|
|
switch totalSleepHours {
|
|
case 8...: return "Excellent"
|
|
case 7..<8: return "Good"
|
|
case 6..<7: return "Fair"
|
|
default: return "Poor"
|
|
}
|
|
}
|
|
|
|
var qualityColor: String {
|
|
switch totalSleepHours {
|
|
case 8...: return "success"
|
|
case 7..<8: return "accent"
|
|
case 6..<7: return "important"
|
|
default: return "error"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BedtimeRoutine: Codable {
|
|
var targetBedtime: Date // Time component only
|
|
var windDownMinutes: Int // Default 30
|
|
var steps: [RoutineStep]
|
|
var enabled: Bool
|
|
|
|
struct RoutineStep: Codable, Identifiable {
|
|
let id: String
|
|
var label: String
|
|
var durationMinutes: Int
|
|
var icon: String
|
|
|
|
init(label: String, durationMinutes: Int, icon: String) {
|
|
self.id = UUID().uuidString
|
|
self.label = label
|
|
self.durationMinutes = durationMinutes
|
|
self.icon = icon
|
|
}
|
|
}
|
|
|
|
static let `default` = BedtimeRoutine(
|
|
targetBedtime: Calendar.current.date(from: DateComponents(hour: 22, minute: 30))!,
|
|
windDownMinutes: 30,
|
|
steps: [
|
|
RoutineStep(label: "Put devices away", durationMinutes: 5, icon: "iphone.slash"),
|
|
RoutineStep(label: "Read or journal", durationMinutes: 15, icon: "book.fill"),
|
|
RoutineStep(label: "Breathing exercise", durationMinutes: 5, icon: "wind"),
|
|
RoutineStep(label: "Lights out", durationMinutes: 5, icon: "moon.fill"),
|
|
],
|
|
enabled: false
|
|
)
|
|
}
|
|
|
|
struct SmartAlarmConfig: Codable {
|
|
var windowStart: Date // e.g. 6:30 AM
|
|
var windowEnd: Date // e.g. 7:00 AM
|
|
var enabled: Bool
|
|
var useSleepData: Bool // Use HealthKit to optimize wake time
|
|
|
|
static let `default` = SmartAlarmConfig(
|
|
windowStart: Calendar.current.date(from: DateComponents(hour: 6, minute: 30))!,
|
|
windowEnd: Calendar.current.date(from: DateComponents(hour: 7, minute: 0))!,
|
|
enabled: false,
|
|
useSleepData: true
|
|
)
|
|
}
|