diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index d873cad..ca350a0 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -6,16 +6,55 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 4C4956450000000000000020 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 373AAED52E91B7E900D8ED84 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4C4956450000000000000030; + remoteInfo = BedtimeLiveActivity; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 4C4956450000000000000040 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 373AAEDD2E91B7E900D8ED84 /* Bedger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bedger.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4C4956450000000000000011 /* BedtimeLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BedtimeLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 371FB5C6302D689B005F6931 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; 373AAEDF2E91B7E900D8ED84 /* Bedtime */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Bedtime; sourceTree = ""; }; + 4C4956450000000000000050 /* BedtimeLiveActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BedtimeLiveActivity; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +65,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4C4956450000000000000060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +79,8 @@ isa = PBXGroup; children = ( 373AAEDF2E91B7E900D8ED84 /* Bedtime */, + 4C4956450000000000000050 /* BedtimeLiveActivity */, + 371FB5C6302D689B005F6931 /* Shared */, 373AAEDE2E91B7E900D8ED84 /* Products */, ); sourceTree = ""; @@ -41,6 +89,7 @@ isa = PBXGroup; children = ( 373AAEDD2E91B7E900D8ED84 /* Bedger.app */, + 4C4956450000000000000011 /* BedtimeLiveActivity.appex */, ); name = Products; sourceTree = ""; @@ -55,12 +104,15 @@ 373AAED92E91B7E900D8ED84 /* Sources */, 373AAEDA2E91B7E900D8ED84 /* Frameworks */, 373AAEDB2E91B7E900D8ED84 /* Resources */, + 4C4956450000000000000040 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 4C4956450000000000000080 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 371FB5C6302D689B005F6931 /* Shared */, 373AAEDF2E91B7E900D8ED84 /* Bedtime */, ); name = Bedtime; @@ -70,6 +122,29 @@ productReference = 373AAEDD2E91B7E900D8ED84 /* Bedger.app */; productType = "com.apple.product-type.application"; }; + 4C4956450000000000000030 /* BedtimeLiveActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4C4956450000000000000090 /* Build configuration list for PBXNativeTarget "BedtimeLiveActivity" */; + buildPhases = ( + 4C49564500000000000000A0 /* Sources */, + 4C4956450000000000000060 /* Frameworks */, + 4C49564500000000000000B0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 371FB5C6302D689B005F6931 /* Shared */, + 4C4956450000000000000050 /* BedtimeLiveActivity */, + ); + name = BedtimeLiveActivity; + packageProductDependencies = ( + ); + productName = BedtimeLiveActivity; + productReference = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -78,11 +153,14 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2660; TargetAttributes = { 373AAEDC2E91B7E900D8ED84 = { CreatedOnToolsVersion = 26.0; }; + 4C4956450000000000000030 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 373AAED82E91B7E900D8ED84 /* Build configuration list for PBXProject "Bedtime" */; @@ -100,6 +178,7 @@ projectRoot = ""; targets = ( 373AAEDC2E91B7E900D8ED84 /* Bedtime */, + 4C4956450000000000000030 /* BedtimeLiveActivity */, ); }; /* End PBXProject section */ @@ -112,6 +191,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4C49564500000000000000B0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -122,8 +208,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4C49564500000000000000A0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4C4956450000000000000080 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4C4956450000000000000030 /* BedtimeLiveActivity */; + targetProxy = 4C4956450000000000000020 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 373AAEEC2E91B7EA00D8ED84 /* Debug */ = { isa = XCBuildConfiguration; @@ -131,15 +232,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bedtime/Bedtime.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9J44X82D53; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -150,14 +248,8 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime; PRODUCT_NAME = Bedger; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -168,15 +260,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Bedtime/Bedtime.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9J44X82D53; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHealthShareUsageDescription = "Use your sleep data to calculate your sleep bank and provide personalized bedtime recommendations."; INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Sync sleep data from Bedger to Apple Health"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIBackgroundModes = processing; @@ -187,14 +276,8 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime; PRODUCT_NAME = Bedger; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -233,6 +316,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 9J44X82D53; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -252,14 +336,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; + MARKETING_VERSION = 0.9.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; name = Debug; @@ -298,6 +386,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 9J44X82D53; ENABLE_NS_ASSERTIONS = NO; @@ -311,17 +400,57 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; + MARKETING_VERSION = 0.9.0; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; }; + 4C49564500000000000000C0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Bedger; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4C49564500000000000000D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Bedger; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -343,6 +472,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4C4956450000000000000090 /* Build configuration list for PBXNativeTarget "BedtimeLiveActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C49564500000000000000C0 /* Debug */, + 4C49564500000000000000D0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 373AAED52E91B7E900D8ED84 /* Project object */; diff --git a/Bedtime/Bedtime/BedtimeApp.swift b/Bedtime/Bedtime/BedtimeApp.swift index 98271ac..ee09749 100644 --- a/Bedtime/Bedtime/BedtimeApp.swift +++ b/Bedtime/Bedtime/BedtimeApp.swift @@ -41,6 +41,12 @@ struct BedtimeApp: App { } }() + init() { + // Must happen synchronously before the app finishes launching, per + // BGTaskScheduler's requirements. + LiveActivityManager.registerBackgroundTask() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 358a686..1a82e55 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -class Constants { +enum Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 static let sleepHistoryDays = 30 @@ -21,6 +21,12 @@ class Constants { /// Selectable nightly sleep goals (Settings slider and the goal editor on the main screen). static let sleepGoalHoursRange = 6.0...12.0 static let sleepGoalStepHours: Double = 0.25 + /// How far ahead of the recommended bedtime the Live Activity appears. + static let liveActivityLeadTime: TimeInterval = .minutes(30) + /// Matches `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers`. A backup + /// for flipping the Live Activity to its post-wake state in case HealthKit + /// background delivery doesn't fire by then. + static let wakeRefreshTaskIdentifier = "com.burnsides.bedtime.background-task" /// Goal choices as step offsets from the lowest goal, so pickers can tag /// options with values that compare exactly. diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index efaaf9c..458776f 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -15,6 +15,7 @@ struct ContentView: View { @Query private var preferences: [UserPreferences] @StateObject private var sourcePreferences: SourcePreferences @StateObject private var healthKitManager: HealthKitManager + @ObservedObject private var liveActivityManager = LiveActivityManager.shared @State private var showingSettings = false @State private var showingError = false @State private var error: Error? @@ -132,6 +133,7 @@ struct ContentView: View { } else { BedtimeRecommendationCard( recommendation: bedtimeRecommendation, + liveActivityManager: liveActivityManager, wakeTime: wakeTimeBinding ) } @@ -164,6 +166,7 @@ struct ContentView: View { if isBeforeEvening { BedtimeRecommendationCard( recommendation: bedtimeRecommendation, + liveActivityManager: liveActivityManager, wakeTime: wakeTimeBinding ) } else { @@ -222,15 +225,45 @@ struct ContentView: View { } .task { try? await healthKitManager.fetchSleepData() + await refreshBedtimePlan() } .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() } + Task { + try? await healthKitManager.fetchSleepData() + await refreshBedtimePlan() + } + } + .onChange(of: bedtimeRecommendation) { _, _ in + // Catches every foreground way the recommendation can move — the + // sleep bank range, sleep goal, or wake time — none of which go + // through `scenePhase` above since the app never left the + // foreground for them. + Task { + await refreshBedtimePlan() + } } } + + /// Records tonight's recommendation for background entry points and brings the + /// Live Activity in line with it. + private func refreshBedtimePlan() async { + let recommendation = bedtimeRecommendation + let durationStyle = userPreferences.durationDisplayStyle + + BedtimePlanStore.save(recommendation, durationStyle: durationStyle) + + await liveActivityManager.syncWithSchedule( + recommendation: recommendation, + durationStyle: durationStyle, + sourceAppLink: recentSourceAppLinks.first.map { + BedtimeSourceAppLink(name: $0.name, url: $0.destination) + } + ) + } } #Preview { diff --git a/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift new file mode 100644 index 0000000..49469eb --- /dev/null +++ b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift @@ -0,0 +1,54 @@ +// +// BedtimeActivityIntents.swift +// Bedtime +// + +import AppIntents + +/// Starts tonight's countdown without bringing the app forward. +/// +/// `LiveActivityIntent` is the sanctioned way to begin a Live Activity from the +/// background, which is what a time-based automation needs: iOS won't wake the app +/// on its own schedule, so the trigger has to come from the system. +struct StartBedtimeCountdownIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "Start Bedtime Countdown" + static let description: IntentDescription? = IntentDescription( + "Shows tonight's bedtime countdown on the Lock Screen and in the Dynamic Island." + ) + + func perform() async throws -> some IntentResult { + await LiveActivityManager.shared.startFromSavedPlan() + return .result() + } +} + +struct EndBedtimeCountdownIntent: LiveActivityIntent { + static let title: LocalizedStringResource = "End Bedtime Countdown" + static let description: IntentDescription? = IntentDescription( + "Removes tonight's bedtime countdown." + ) + + func perform() async throws -> some IntentResult { + await LiveActivityManager.shared.end() + return .result() + } +} + +/// Surfaces both actions to Shortcuts and Siri so they can be attached to a +/// personal automation that runs at a fixed time each evening. +struct BedgerShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartBedtimeCountdownIntent(), + phrases: ["Start my \(.applicationName) countdown"], + shortTitle: "Start Bedtime Countdown", + systemImageName: "bed.double.fill" + ) + AppShortcut( + intent: EndBedtimeCountdownIntent(), + phrases: ["End my \(.applicationName) countdown"], + shortTitle: "End Bedtime Countdown", + systemImageName: "moon.zzz.fill" + ) + } +} diff --git a/Bedtime/Bedtime/Models/BedtimePlanStore.swift b/Bedtime/Bedtime/Models/BedtimePlanStore.swift new file mode 100644 index 0000000..542fef9 --- /dev/null +++ b/Bedtime/Bedtime/Models/BedtimePlanStore.swift @@ -0,0 +1,65 @@ +// +// BedtimePlanStore.swift +// Bedtime +// + +import Foundation + +/// A snapshot of the most recent recommendation. +/// +/// Computing a recommendation needs HealthKit and the SwiftData preferences, which +/// is more than a background launch should take on. Saving the answer lets an +/// automation start tonight's Live Activity from whatever the app last worked out. +struct BedtimePlan: Codable { + let recommendedBedtime: Date + let wakeTime: Date + let targetSleepHours: Double + let durationStyle: DurationDisplayStyle + let savedAt: Date +} + +enum BedtimePlanStore { + /// Plans older than this are treated as missing; the schedule they were built + /// from has probably moved on. + static let maximumAge: TimeInterval = .days(7) + + private static let key = "tonightsBedtimePlan" + + static func save( + _ recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle, + now: Date = Date() + ) { + let plan = BedtimePlan( + recommendedBedtime: recommendation.recommendedBedtime, + wakeTime: recommendation.wakeTime, + targetSleepHours: recommendation.targetSleepDuration, + durationStyle: durationStyle, + savedAt: now + ) + guard let data = try? JSONEncoder().encode(plan) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + static func current(now: Date = Date()) -> BedtimePlan? { + guard + let data = UserDefaults.standard.data(forKey: key), + let plan = try? JSONDecoder().decode(BedtimePlan.self, from: data), + now.timeIntervalSince(plan.savedAt) < maximumAge + else { + return nil + } + return plan + } +} + +extension BedtimeRecommendation { + init(plan: BedtimePlan) { + self.init( + recommendedBedtime: plan.recommendedBedtime, + wakeTime: plan.wakeTime, + targetSleepDuration: plan.targetSleepHours, + reason: nil + ) + } +} diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index 1af4dc3..9929be4 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -182,6 +182,12 @@ class HealthKitManager: ObservableObject { } catch { self.errorMessage = "Failed to refresh sleep data: \(error.localizedDescription)" } + // New sleep data is the best signal that the person is awake — + // often better than the wake-refresh background task, since it + // fires when data actually lands rather than guessing a time. + // No-ops if there's no activity, it's not sleeping, or it's + // already marked awake. + await LiveActivityManager.shared.markAwakeIfNeeded() } } diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift new file mode 100644 index 0000000..f8152ed --- /dev/null +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -0,0 +1,441 @@ +import ActivityKit +import BackgroundTasks +import Combine +import Foundation + +@MainActor +final class LiveActivityManager: ObservableObject { + /// Shared so an app intent launched in the background drives the same + /// activity the UI is showing. + static let shared = LiveActivityManager() + + @Published private(set) var activeActivityID: String? + @Published private(set) var isWorking = false + @Published private(set) var lastErrorMessage: String? + + var isActivityActive: Bool { + activeActivityID != nil + } + + /// The widget extension deploys to iOS 18 because its countdown relies on + /// `SystemFormatStyle`, so on older systems there is no activity to request. + var isSupported: Bool { + if #available(iOS 18, *) { + return true + } + return false + } + + var areActivitiesEnabled: Bool { + ActivityAuthorizationInfo().areActivitiesEnabled + } + + init() { + activeActivityID = (Self.currentlyVisibleActivity() ?? Activity.activities.first)?.id + } + + /// Registers the background refresh task handler. Apple requires this to + /// happen synchronously before the app finishes launching, so call it from + /// `BedtimeApp.init()` rather than anywhere the shared instance is used — + /// registration doesn't depend on there being an activity yet. `nonisolated` + /// since `App.init()` isn't MainActor-isolated; the launch handler still + /// hops to the main actor itself before touching `.shared`. + nonisolated static func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Constants.wakeRefreshTaskIdentifier, + using: nil + ) { task in + // BGTask isn't Sendable, but the system allows calling + // setTaskCompleted from any context — matches the HealthKit + // observer's completionHandler workaround. + nonisolated(unsafe) let task = task + Task { @MainActor in + await LiveActivityManager.shared.markAwakeIfNeeded() + task.setTaskCompleted(success: true) + } + } + } + + func newStartTime(bedtime: Date, now: Date) -> Date { + // A fixed elapsed-time subtraction rather than calendar arithmetic: the + // lead time should always be an actual 30 minutes, not a wall-clock gap + // that a DST transition happening to land near bedtime could stretch or shrink. + let defaultTime = bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) + return min(now, defaultTime) + } + + func startOrUpdate( + with recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle, + sourceAppLink: BedtimeSourceAppLink? = nil + ) async { + guard isSupported else { + lastErrorMessage = "Live Activities need iOS 18 or later." + return + } + + guard areActivitiesEnabled else { + lastErrorMessage = "Live Activities are disabled for Bedger in system settings." + return + } + + isWorking = true + lastErrorMessage = nil + defer { isWorking = false } + + let now = Date() + let schedule = getTimes(for: recommendation, now: now) + let isSleeping = now >= schedule.bedtime + // Prefer whatever's actually on screen over the cached + // `activeActivityID`: once the multi-night queue is in play, that + // cache can point at a `.pending` future night instead of tonight's + // visible activity, silently updating the wrong one. + // + // Looked up fresh at each use below via the nonisolated static + // helper rather than stored in a shared `let`: once an `Activity` + // has been read synchronously within this actor-isolated function, + // the compiler can no longer prove it's safe to send into + // `await update(...)` further down. + let startTime = Self.existingActivity(cachedID: activeActivityID)?.content.state.activityStart + ?? newStartTime(bedtime: schedule.bedtime, now: now) + let state = BedtimeActivityAttributes.ContentState( + activityStart: startTime, + bedtime: schedule.bedtime, + wakeTime: schedule.wakeTime, + targetSleepHours: recommendation.targetSleepDuration, + durationStyle: durationStyle, + isSleeping: isSleeping, + sourceAppLink: sourceAppLink + ) + // Going stale at bedtime flips the card to the sleeping countdown while + // the app is suspended. Starting mid-night, bedtime has already gone by, + // so the state above carries the phase and this stays ahead of now. + let content = ActivityContent( + state: state, + staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime + ) + + if let activity = Self.existingActivity(cachedID: activeActivityID), activity.activityState == .active || activity.activityState == .stale { + // `.stale` still counts as on screen: the sleeping phase is + // reached by deliberately letting the wind-down content go stale + // at bedtime, so this is the common case for most of the night, + // not an edge case. + await activity.update(content) + activeActivityID = activity.id + return + } + + // Nothing to update in place, so this is about to request a new + // activity. Clear every `.pending` one first, not just whichever + // `existingActivity` happened to resolve to — the multi-night queue + // can leave several scheduled at once, and each still counts against + // the 5-concurrent-activity budget, so leaving them made `request` + // below fail with `targetMaximumExceeded` even though nothing was + // visibly on screen. Starting/updating right now always takes + // priority over a background pre-scheduled queue, which gets rebuilt + // fresh next time `syncWithSchedule` runs anyway. + // + // `.pending` (and the multi-night queue that produces it) only + // exists on iOS 26+; on older systems there's nothing to clear. + if #available(iOS 26, *) { + for activity in Activity.activities where activity.activityState == .pending { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + activeActivityID = nil + + do { + let activity = try Activity.request( + attributes: BedtimeActivityAttributes(), + content: content, + pushType: nil + ) + activeActivityID = activity.id + scheduleWakeRefreshTask(at: schedule.wakeTime) + } catch { + lastErrorMessage = error.localizedDescription + } + } + + /// Flips the activity to its post-wake phase once `wakeTime` has passed, + /// without recomputing the recommendation: this runs from a HealthKit + /// background delivery callback or the wake-refresh background task, + /// neither of which has cheap access to the full recommendation inputs + /// (HealthKit averages, SwiftData preferences) — nor needs them, since + /// every other field on the activity is left exactly as it was. + func markAwakeIfNeeded(now: Date = Date()) async { + guard let activity = Self.currentlyVisibleActivity() else { return } + + let state = activity.content.state + guard state.isSleeping, !state.isAwake, now >= state.wakeTime else { return } + + var updatedState = state + updatedState.isAwake = true + // Nothing left to wait for, so there's no future date to go stale at. + await activity.update(ActivityContent(state: updatedState, staleDate: nil)) + activeActivityID = activity.id + } + + /// Best-effort backup for `markAwakeIfNeeded`, in case HealthKit doesn't + /// deliver new sleep data by wake time (no watch/ring worn, slow sync, + /// etc). Not time-precise — iOS decides when to actually run it. + /// + /// `BGProcessingTaskRequest` rather than `BGAppRefreshTaskRequest`: the + /// latter needs the `fetch` UIBackgroundMode, but only `processing` is + /// declared. The work itself is trivial, so the conditions that usually + /// make processing tasks wait for a convenient moment are turned off. + private func scheduleWakeRefreshTask(at wakeTime: Date) { + let request = BGProcessingTaskRequest(identifier: Constants.wakeRefreshTaskIdentifier) + request.earliestBeginDate = wakeTime + request.requiresNetworkConnectivity = false + request.requiresExternalPower = false + try? BGTaskScheduler.shared.submit(request) + } + + /// Starts the activity once the wind-down window opens, refreshes it if the + /// schedule moved, and clears out last night's once the wake time has passed. + /// Safe to call on every foreground. + func syncWithSchedule( + recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle, + leadTime: TimeInterval = Constants.liveActivityLeadTime, + now: Date = Date(), + sourceAppLink: BedtimeSourceAppLink? = nil + ) async { + await endFinishedActivities(now: now) + + guard isSupported, areActivitiesEnabled else { return } + + let schedule = getTimes(for: recommendation, now: now) + // Same reasoning as `newStartTime`: an actual elapsed-time lead, not a + // wall-clock gap that could stretch or shrink across a DST transition. + let windowOpens = schedule.bedtime.addingTimeInterval(-leadTime) + + // Once bedtime passes, the next match is tomorrow's, so this only opens + // the window ahead of tonight's bedtime. An activity already running + // carries the rest of the night on its own. + guard now < windowOpens else { + await startOrUpdate(with: recommendation, durationStyle: durationStyle, sourceAppLink: sourceAppLink) + return + } + + if #available(iOS 26, *) { + await scheduleUpcomingNights( + recommendation: recommendation, + durationStyle: durationStyle, + schedule: schedule, + startingAt: windowOpens, + sourceAppLink: sourceAppLink + ) + } + } + + /// Hands the starts over to the system so the wind-down card appears on its + /// own, and pre-schedules the following nights too, since starting one from + /// a background wake throws `ActivityAuthorizationError.visibility` — every + /// other route needs either a push server or a user-triggered intent. + /// + /// The following nights just repeat tonight's bedtime/wake clock times: + /// without another HealthKit-backed recalculation there's no better guess, + /// but a stale countdown (flagged as such in the widget) beats no countdown + /// at all until the person reopens the app. Only `.pending` activities are + /// ever touched here — one already `.active` and on screen is left alone. + @available(iOS 26, *) + private func scheduleUpcomingNights( + recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle, + schedule: (bedtime: Date, wakeTime: Date), + startingAt start: Date, + sourceAppLink: BedtimeSourceAppLink? = nil + ) async { + let calendar = Calendar.autoupdatingCurrent + let activeCount = Activity.activities + .filter { $0.activityState == .active } + .count + let nightCount = max(0, BedtimeActivityAttributes.maxConcurrentActivities - activeCount) + guard nightCount > 0 else { return } + + let projectedNights: [(start: Date, bedtime: Date, wakeTime: Date)] = (0...activities + .filter { $0.activityState == .pending } + .map(\.content.state.bedtime) + ) + let desiredBedtimes = Set(projectedNights.map(\.bedtime)) + guard pendingBedtimes != desiredBedtimes else { return } + + // Re-derived rather than reused from `pendingBedtimes` above: once an + // `Activity` has been read from within this actor-isolated region, the + // compiler can no longer prove it's safe to send into `await end(...)` + // below. A fresh, standalone expression avoids that. + for activity in Activity.activities where activity.activityState == .pending { + await activity.end(nil, dismissalPolicy: .immediate) + } + + for (index, night) in projectedNights.enumerated() { + let state = BedtimeActivityAttributes.ContentState( + activityStart: night.start, + bedtime: night.bedtime, + wakeTime: night.wakeTime, + targetSleepHours: recommendation.targetSleepDuration, + durationStyle: durationStyle, + // Scheduled activities always begin ahead of bedtime. + isSleeping: false, + nightsSinceLastSync: index, + sourceAppLink: sourceAppLink + ) + + do { + let activity = try Activity.request( + attributes: BedtimeActivityAttributes(), + content: ActivityContent(state: state, staleDate: night.bedtime), + pushType: nil, + style: .standard, + alertConfiguration: AlertConfiguration( + title: "Time to wind down", + body: "Bedtime is coming up.", + sound: .default + ), + start: night.start + ) + if index == 0 { + activeActivityID = activity.id + scheduleWakeRefreshTask(at: night.wakeTime) + } + } catch { + if index == 0 { + lastErrorMessage = error.localizedDescription + } + // The remaining budget is likely exhausted too; stop rather + // than fail through the rest of the queue one at a time. + break + } + } + } + + /// Starts tonight's activity from the last saved plan, for entry points that + /// can't recompute a recommendation, such as a background app intent. + func startFromSavedPlan(now: Date = Date()) async { + guard let plan = BedtimePlanStore.current(now: now) else { + lastErrorMessage = "Open Bedger once so it can work out tonight's bedtime." + return + } + + await startOrUpdate( + with: BedtimeRecommendation(plan: plan), + durationStyle: plan.durationStyle + ) + } + + func end() async { + isWorking = true + lastErrorMessage = nil + defer { isWorking = false } + + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + activeActivityID = nil + } + + private func endFinishedActivities(now: Date) async { + for activity in Activity.activities + where activity.content.state.wakeTime <= now { + await activity.end(nil, dismissalPolicy: .immediate) + if activity.id == activeActivityID { + activeActivityID = nil + } + } + } + + /// Static so the returned activity stays out of the main-actor region and + /// its async `update`/`end` calls can be sent under strict concurrency. + private nonisolated static func activity(withID id: String?) -> Activity? { + guard let id else { return nil } + return Activity.activities.first { + $0.id == id + } + } + + /// The activity actually on screen right now, if any. `.stale` counts — + /// that's how the sleeping phase is reached, not a separate case. + /// + /// Preferred over trusting `activeActivityID` for anything that should + /// act on what's visible: that property is only a cache, and once the + /// multi-night queue is in play `Activity.activities` can also contain + /// several `.pending` future nights, so picking the wrong entry (or an + /// `.id` left pointing at one) would silently update or end the wrong + /// activity instead of the one on screen. + private nonisolated static func currentlyVisibleActivity() -> Activity? { + Activity.activities.first { + $0.activityState == .active || $0.activityState == .stale + } + } + + /// `currentlyVisibleActivity()`, falling back to the cached ID. Takes the + /// ID as a parameter rather than reading `activeActivityID` directly: a + /// local function referencing `self` would still inherit this actor's + /// isolation at its call site, tainting the returned `Activity` for the + /// main-actor region the same way a stored `let` does — `nonisolated + /// static` is what actually keeps it out. + private nonisolated static func existingActivity(cachedID: String?) -> Activity? { + currentlyVisibleActivity() ?? activity(withID: cachedID) + } + + private func getTimes( + for recommendation: BedtimeRecommendation, + now: Date = Date(), + calendar: Calendar = .current + ) -> (bedtime: Date, wakeTime: Date) { + let bedtimeComponents = calendar.dateComponents( + [.hour, .minute], + from: recommendation.recommendedBedtime + ) + let wakeTimeComponents = calendar.dateComponents( + [.hour, .minute], + from: recommendation.wakeTime + ) + + func occurrence( + of components: DateComponents, + from date: Date, + direction: Calendar.SearchDirection + ) -> Date? { + calendar.nextDate( + after: date, + matching: components, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: direction + ) + } + + // Once bedtime has passed, the night underway is the one to show. Always + // reaching for the next bedtime would jump a whole day forward and take + // the wake time with it. + if + let lastBedtime = occurrence(of: bedtimeComponents, from: now, direction: .backward), + let wakeAfterLastBedtime = occurrence(of: wakeTimeComponents, from: lastBedtime, direction: .forward), + now < wakeAfterLastBedtime + { + return (lastBedtime, wakeAfterLastBedtime) + } + + let bedtime = occurrence(of: bedtimeComponents, from: now, direction: .forward) + ?? recommendation.recommendedBedtime + let wakeTime = occurrence(of: wakeTimeComponents, from: bedtime, direction: .forward) + ?? recommendation.wakeTime + + return (bedtime, wakeTime) + } +} diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index e1688ee..d8a5e7b 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -30,7 +30,7 @@ struct SleepSession { var dateForGrouping: Date { let midpoint = startDate.addingTimeInterval(duration / 2) - let shiftedMidpoint = midpoint.addingTimeInterval(TimeInterval(6 * 60 * 60)) + let shiftedMidpoint = midpoint.addingTimeInterval(.hours(6)) return Calendar.current.startOfDay(for: shiftedMidpoint) } } @@ -162,7 +162,7 @@ struct SleepBank: Equatable { } -struct BedtimeRecommendation { +struct BedtimeRecommendation: Equatable { let recommendedBedtime: Date let wakeTime: Date let targetSleepDuration: Double // in hours diff --git a/Bedtime/Bedtime/Models/SourcePreferences.swift b/Bedtime/Bedtime/Models/SourcePreferences.swift index 1501114..689836d 100644 --- a/Bedtime/Bedtime/Models/SourcePreferences.swift +++ b/Bedtime/Bedtime/Models/SourcePreferences.swift @@ -11,6 +11,7 @@ import Combine import HealthKit /// Manages user preferences for which HealthKit sources to include in sleep data +@MainActor class SourcePreferences: ObservableObject { private static let excludedSourcesKey = "excludedSleepSourceBundleIdentifiers" diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 40708a2..1996904 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -114,22 +114,22 @@ class ViewModel { // Calculate how much sleep we need tonight // If we're in debt, we need extra sleep to catch up - var totalSleepNeeded = sleepGoal - sleepBank.currentBalance - + var totalHoursNeeded = sleepGoal - sleepBank.currentBalance + // Generate reason let reason: String? if sleepBank.averageHours == nil { reason = "No data so far, just aim for your goal" - } else if totalSleepNeeded > maxSleepHours { - totalSleepNeeded = maxSleepHours + } else if totalHoursNeeded > maxSleepHours { + totalHoursNeeded = maxSleepHours reason = nil } else if sleepBank.isInDebt { let debtHours = sleepBank.debtHours - let needed = TimeFormatter.formatHours(totalSleepNeeded, style: durationStyle) + let needed = TimeFormatter.formatHours(totalHoursNeeded, style: durationStyle) let debt = TimeFormatter.formatHours(debtHours, style: durationStyle) reason = "You need \(needed) tonight to catch up on your \(debt) sleep debt." } else { - totalSleepNeeded = sleepGoal + totalHoursNeeded = sleepGoal let goal = TimeFormatter.formatHours( sleepGoal, style: durationStyle, @@ -138,13 +138,15 @@ class ViewModel { reason = "You're ahead of the game! Aim for at least \(goal) tonight." } - let sleepNeededMinutes = Int((totalSleepNeeded * 60).rounded()) - let recommendedBedtime = calendar.date(byAdding: .minute, value: -sleepNeededMinutes, to: wakeTime) ?? wakeTime.addingTimeInterval(-totalSleepNeeded * 60 * 60) - + // A fixed elapsed-time subtraction rather than calendar arithmetic: what + // matters is actually sleeping for this many hours, not a wall-clock gap + // that a DST transition overnight could stretch or shrink by an hour. + let recommendedBedtime = wakeTime.addingTimeInterval(-.hours(totalHoursNeeded)) + return BedtimeRecommendation( recommendedBedtime: recommendedBedtime, wakeTime: wakeTime, - targetSleepDuration: totalSleepNeeded, + targetSleepDuration: totalHoursNeeded, reason: reason ) } diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift index 001fac8..f9c6551 100644 --- a/Bedtime/Bedtime/Utils/SleepWindow.swift +++ b/Bedtime/Bedtime/Utils/SleepWindow.swift @@ -10,7 +10,7 @@ import Foundation /// Pure time-interval math for the sleep window between an earliest reasonable /// bedtime and a wake time. Split into a now-aware (DST-adjusted) measure for the /// recommendation and a nominal wall-clock measure for schedule display. -nonisolated struct SleepWindow { +struct SleepWindow { /// Real hours between the earliest reasonable bedtime and wake time for the /// night ending at the next wake from `referenceDate`. Anchoring on the wake /// (forward from now) then searching backward for the bedtime keeps the diff --git a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift index fe1c060..337f194 100644 --- a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift +++ b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift @@ -9,9 +9,11 @@ import SwiftUI struct BedtimeRecommendationCard: View { let recommendation: BedtimeRecommendation + @ObservedObject var liveActivityManager: LiveActivityManager /// When provided, the wake time is tappable and edits this value directly. var wakeTime: Binding? = nil + @Environment(\.durationDisplayStyle) private var durationStyle @State private var isEditingWakeTime = false var body: some View { @@ -57,11 +59,73 @@ struct BedtimeRecommendationCard: View { .foregroundColor(.secondary) .multilineTextAlignment(.leading) } + + if liveActivityManager.isSupported { + Divider() + + liveActivityControls + } } } } } + @ViewBuilder + private var liveActivityControls: some View { + if liveActivityManager.areActivitiesEnabled { + HStack(spacing: 12) { + Button { + Task { + await liveActivityManager.startOrUpdate( + with: recommendation, + durationStyle: durationStyle + ) + } + } label: { + Label( + liveActivityManager.isActivityActive + ? "Update Live Activity" + : "Start Live Activity", + systemImage: "wave.3.right.circle.fill" + ) + } + .buttonStyle(.borderedProminent) + .disabled(liveActivityManager.isWorking) + + if liveActivityManager.isActivityActive { + Button("End", role: .destructive) { + Task { + await liveActivityManager.end() + } + } + .buttonStyle(.bordered) + .disabled(liveActivityManager.isWorking) + } + + if liveActivityManager.isWorking { + ProgressView() + .controlSize(.small) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Label( + "Enable Live Activities for Bedger in system settings.", + systemImage: "exclamationmark.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let errorMessage = liveActivityManager.lastErrorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + private func editableWakeTime(_ wakeTime: Binding) -> some View { Button { isEditingWakeTime = true @@ -138,6 +202,7 @@ private struct WakeTimeEditor: View { targetSleepDuration: 8, reason: "You're ahead of the game! Aim for at least 8h tonight." ), + liveActivityManager: LiveActivityManager(), wakeTime: $wakeTime ) .padding() diff --git a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift index 1e2c379..0e71efe 100644 --- a/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift +++ b/Bedtime/Bedtime/Views/Components/SleepSourceComparisonView.swift @@ -122,10 +122,10 @@ struct SleepStageTimelineBar: View { SleepSourceComparisonView( sessions: [ - SleepSession(startDate: bedTime, endDate: bedTime.addingTimeInterval(3600), sleepType: .asleepCore, source: sourceA), - SleepSession(startDate: bedTime.addingTimeInterval(3600), endDate: bedTime.addingTimeInterval(7200), sleepType: .asleepDeep, source: sourceA), - SleepSession(startDate: bedTime.addingTimeInterval(300), endDate: bedTime.addingTimeInterval(5400), sleepType: .asleepCore, source: sourceB), - SleepSession(startDate: bedTime.addingTimeInterval(5400), endDate: bedTime.addingTimeInterval(7800), sleepType: .asleepREM, source: sourceB), + SleepSession(startDate: bedTime, endDate: bedTime.addingTimeInterval(.hours(1)), sleepType: .asleepCore, source: sourceA), + SleepSession(startDate: bedTime.addingTimeInterval(.hours(1)), endDate: bedTime.addingTimeInterval(.hours(2)), sleepType: .asleepDeep, source: sourceA), + SleepSession(startDate: bedTime.addingTimeInterval(.minutes(5)), endDate: bedTime.addingTimeInterval(.minutes(90)), sleepType: .asleepCore, source: sourceB), + SleepSession(startDate: bedTime.addingTimeInterval(.minutes(90)), endDate: bedTime.addingTimeInterval(.minutes(130)), sleepType: .asleepREM, source: sourceB), ], excludedSourceIDs: ["com.example.disabled"] ) diff --git a/Bedtime/Bedtime/Views/LastNightCard.swift b/Bedtime/Bedtime/Views/LastNightCard.swift index b3d2c7e..814f806 100644 --- a/Bedtime/Bedtime/Views/LastNightCard.swift +++ b/Bedtime/Bedtime/Views/LastNightCard.swift @@ -109,7 +109,7 @@ import HealthKit private func previewNight(hours: Double) -> SleepSession { let wakeTime = Calendar.current.date(bySettingHour: 6, minute: 35, second: 0, of: Date()) ?? Date() return SleepSession( - startDate: wakeTime.addingTimeInterval(-hours * 3600), + startDate: wakeTime.addingTimeInterval(.hours(-hours)), endDate: wakeTime, sleepType: .asleepUnspecified, source: .init(source: .default(), version: nil) diff --git a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift index b1d29cd..2f21727 100644 --- a/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift +++ b/Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift @@ -133,7 +133,7 @@ private struct RecentSleepSessionsCardPreview: View { else { continue } sessions[night] = [ SleepSession( - startDate: wakeTime.addingTimeInterval(-hours * 3600), + startDate: wakeTime.addingTimeInterval(.hours(-hours)), endDate: wakeTime, sleepType: .asleepUnspecified, source: .init(source: .default(), version: nil) diff --git a/Bedtime/BedtimeLiveActivity-Info.plist b/Bedtime/BedtimeLiveActivity-Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/Bedtime/BedtimeLiveActivity-Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift new file mode 100644 index 0000000..27713dc --- /dev/null +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -0,0 +1,425 @@ +import ActivityKit +import Foundation +import SwiftUI +import WidgetKit + +@main +struct BedtimeLiveActivityBundle: WidgetBundle { + var body: some Widget { + BedtimeLiveActivity() + } +} + +struct BedtimeLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: BedtimeActivityAttributes.self) { context in + BedtimeSummaryView( + state: context.state, + phase: BedtimePhase(context) + ) + .padding() + .activitySystemActionForegroundColor(.primary) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.center) { + BedtimeSummaryView( + state: context.state, + phase: BedtimePhase(context) + ) + } + } compactLeading: { + let phase = BedtimePhase(context) + HStack(spacing: 3) { + Image(systemName: phase.symbol) + Text(phase.compactLabel) + } + .foregroundStyle(.indigo) + .lineLimit(1) + } compactTrailing: { + let phase = BedtimePhase(context) + if phase == .awake { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.indigo) + } else { + VStack(spacing: 2) { + CountdownText(state: context.state, phase: phase) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + + PhaseProgressBar(state: context.state, phase: phase) + .frame(height: 2) + } + // Text doesn't hug so without a cap the region grows to + // fill the whole side of the status bar. A phrase like + // "in 26 minutes" is too wide for one line even at this + // width, so it's allowed to wrap instead of shrinking to + // the point of being unreadable. + .frame(maxWidth: 46) + } + } minimal: { + let phase = BedtimePhase(context) + if phase == .awake { + Image(systemName: phase.symbol) + .foregroundStyle(.indigo) + } else { + ProgressView( + timerInterval: context.state.progressRange(for: phase), + countsDown: phase == .windDown + ) { + EmptyView() + } currentValueLabel: { + Image(systemName: phase.symbol) + .padding(2) + } + .progressViewStyle(.circular) + .tint(.indigo) + } + } + .keylineTint(.indigo) + } + } +} + +/// The activity covers three stretches: wind-down, sleeping, and — once +/// `LiveActivityManager.markAwakeIfNeeded()` flips `isAwake` — done. The first +/// transition happens locally when `staleDate` (set to bedtime) passes; the +/// second can't, since `staleDate` only lapses once, so it needs the app to +/// actually run and push that update. +private enum BedtimePhase { + case windDown + case sleeping + case awake + + init(_ context: ActivityViewContext) { + if context.state.isAwake { + self = .awake + } else { + // Either the activity began after bedtime, or it has since gone + // stale at bedtime and the system re-rendered it. + self = context.state.isSleeping || context.isStale ? .sleeping : .windDown + } + } + + var symbol: String { + switch self { + case .windDown: "bed.double.fill" + case .sleeping: "moon.zzz.fill" + case .awake: "sun.max.fill" + } + } + + /// `DateReference` supplies its own "in …", so this is only the subject. + var countdownLabel: String { + switch self { + case .windDown: "Bedtime" + case .sleeping: "Wake" + case .awake: "Awake" + } + } + + /// Names what the compact pill is counting down to, since its trailing text + /// is only a bare duration. + var compactLabel: String { + switch self { + case .windDown: "Sleep" + case .sleeping: "Wake" + case .awake: "Awake" + } + } +} + +private extension BedtimeActivityAttributes.ContentState { + + func start(for phase: BedtimePhase) -> Date { + switch phase { + case .windDown: activityStart + case .sleeping, .awake: bedtime + } + } + + func target(for phase: BedtimePhase) -> Date { + switch phase { + case .windDown: bedtime + case .sleeping, .awake: wakeTime + } + } + + func progressRange(for phase: BedtimePhase) -> ClosedRange { + let start = start(for: phase) + let end = target(for: phase) + return min(start, end)...max(start, end) + } +} + +/// How insistently the card nudges toward reopening the app, based on how many +/// nights into a locally pre-scheduled queue this one is (see the app's +/// `LiveActivityManager.scheduleUpcomingNights`). Night 0 is a real, +/// HealthKit-backed recommendation; every night after that just repeats the +/// same clock times, so the nudge gets louder the further out it is. +private enum ReminderLevel: Equatable { + case gentle + case final + + init?(nightsSinceLastSync: Int) { + switch nightsSinceLastSync { + case ..<2: return nil + case 2..<(BedtimeActivityAttributes.maxConcurrentActivities - 1): self = .gentle + default: self = .final + } + } + + var message: String { + switch self { + case .gentle: "Open the app for an accurate recommendation" + case .final: "Open the app now to keep your countdown going" + } + } +} + +/// Only the system format styles keep counting while the app is suspended, so the +/// remaining time is rendered by the system rather than formatted by the app. +/// +/// Limiting the fields to hours and minutes keeps the phrasing at "in 8 hours" / +/// "in 20 minutes" rather than reaching for "tomorrow", and the hour threshold means +/// only a gap wider than a day would fall back to an absolute date. +private struct CountdownText: View { + let state: BedtimeActivityAttributes.ContentState + let phase: BedtimePhase + + var body: some View { + Text( + .currentDate, + format: .reference( + to: state.target(for: phase), + allowedFields: [.hour, .minute], + thresholdField: .hour + ) + ) + } +} + +/// The bar always anchors its fill to the leading edge, so when it counts down +/// the emptiness grows from the trailing side — backwards next to the schedule +/// row below it. Inverting the inherited layout direction makes it drain from +/// the leading side instead; while sleeping the bar counts up and its +/// leading-to-trailing fill toward Wake already reads correctly, so it keeps +/// the inherited direction. +private struct PhaseProgressBar: View { + let state: BedtimeActivityAttributes.ContentState + let phase: BedtimePhase + + @Environment(\.layoutDirection) private var layoutDirection + + private var flippedLayoutDirection: LayoutDirection { + layoutDirection == .leftToRight ? .rightToLeft : .leftToRight + } + + var body: some View { + ProgressView( + timerInterval: state.progressRange(for: phase), + countsDown: phase == .windDown + ) { + EmptyView() + } currentValueLabel: { + EmptyView() + } + .tint(.indigo) + .environment(\.layoutDirection, phase == .windDown ? flippedLayoutDirection : layoutDirection) + } +} + +private struct BedtimeSummaryView: View { + let state: BedtimeActivityAttributes.ContentState + let phase: BedtimePhase + + private var targetText: String { + // The default single digit: this is a computed duration, not one of the + // quarter-hour goals that need hundredths to read correctly. + TimeFormatter.formatHours(state.targetSleepHours, style: state.durationStyle) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if phase == .awake { + awakeContent + } else { + countdownContent + } + } + } + + private var awakeContent: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Good morning", systemImage: phase.symbol) + .font(.headline) + .foregroundStyle(.indigo) + + if let link = state.sourceAppLink { + Link(destination: link.url) { + Label("Open \(link.name) to see your data", systemImage: "arrow.up.right") + } + .font(.subheadline) + } else { + Text("Open Bedger to see your sleep data") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var countdownContent: some View { + HStack(spacing: 4) { + Image(systemName: phase.symbol) + .foregroundStyle(.indigo) + .padding(.trailing, 2) + + Text(phase.countdownLabel) + + CountdownText(state: state, phase: phase) + .fontWeight(.semibold) + + Spacer(minLength: 0) + } + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + + PhaseProgressBar(state: state, phase: phase) + + HStack(alignment: .firstTextBaseline) { + scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) + + Spacer(minLength: 8) + + VStack { + Text("Target") + .font(.caption) + .foregroundStyle(.secondary) + Text(targetText) + .font(.headline) + } + + Spacer(minLength: 8) + + scheduleTime(label: "Wake", date: state.wakeTime, alignment: .trailing) + } + + if let reminder = ReminderLevel(nightsSinceLastSync: state.nightsSinceLastSync) { + Label(reminder.message, systemImage: "arrow.triangle.2.circlepath") + .font(.caption2) + .foregroundStyle(reminder == .final ? .red : .secondary) + } + } + + private func scheduleTime( + label: String, + date: Date, + alignment: HorizontalAlignment + ) -> some View { + VStack(alignment: alignment, spacing: 1) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + + Text(TimeFormatter.formatTimeOfDay(date)) + .font(.headline) + } + } +} + +extension BedtimeActivityAttributes.ContentState { + /// Wind-down: bedtime is still ahead. + fileprivate static var windDownSample: Self { + let now = Date() + return Self( + activityStart: now.addingTimeInterval(.minutes(-30)), + bedtime: now.addingTimeInterval(.hours(2)), + wakeTime: now.addingTimeInterval(.hours(10)), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: false + ) + } + + /// Sleeping: bedtime has passed, counting down to wake. + fileprivate static var sleepingSample: Self { + let now = Date() + return Self( + activityStart: now.addingTimeInterval(.hours(-3)), + bedtime: now.addingTimeInterval(.hours(-1)), + wakeTime: now.addingTimeInterval(.hours(7)), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: true + ) + } + + /// Awake: wake time has passed and `markAwakeIfNeeded()` marked it done. + fileprivate static var awakeSample: Self { + let now = Date() + return Self( + activityStart: now.addingTimeInterval(.hours(-8)), + bedtime: now.addingTimeInterval(.hours(-8)), + wakeTime: now.addingTimeInterval(.minutes(-5)), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: true, + sourceAppLink: BedtimeSourceAppLink( + name: "Oura", + url: URL(string: "https://cloud.ouraring.com/app/v1/home")! + ), + isAwake: true + ) + } + + /// The last night of a pre-scheduled queue, several days without a fresh + /// HealthKit-backed recommendation. + fileprivate static var staleQueuedSample: Self { + let now = Date() + return Self( + activityStart: now.addingTimeInterval(.minutes(-30)), + bedtime: now.addingTimeInterval(.hours(2)), + wakeTime: now.addingTimeInterval(.hours(10)), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: false, + nightsSinceLastSync: BedtimeActivityAttributes.maxConcurrentActivities - 1 + ) + } +} + +#Preview("Lock Screen", as: .content, using: BedtimeActivityAttributes()) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample + BedtimeActivityAttributes.ContentState.staleQueuedSample +} + +#Preview("Island Expanded", as: .dynamicIsland(.expanded), using: BedtimeActivityAttributes()) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample +} + +#Preview("Island Compact", as: .dynamicIsland(.compact), using: BedtimeActivityAttributes()) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample +} + +#Preview("Island Minimal", as: .dynamicIsland(.minimal), using: BedtimeActivityAttributes()) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample +} + diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift new file mode 100644 index 0000000..c93cff2 --- /dev/null +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -0,0 +1,51 @@ +import ActivityKit +import Foundation + +/// A deep link to the third-party app that actually wrote recent sleep data to +/// HealthKit (e.g. Oura), so the card can point there directly once the person +/// wakes up instead of a bare "open Bedger" that doesn't have the data either. +struct BedtimeSourceAppLink: Codable, Hashable { + let name: String + let url: URL +} + +struct BedtimeActivityAttributes: ActivityAttributes { + /// The system caps an app at this many concurrent Live Activities, counting + /// both active and merely `.pending` (scheduled but not yet started) ones. + /// Shared with the widget extension so its staleness messaging can key off + /// the same ceiling `LiveActivityManager` schedules against. + static let maxConcurrentActivities = 5 + + struct ContentState: Codable, Hashable { + /// When the activity was started, used as the origin of the wind-down progress bar. + let activityStart: Date + let bedtime: Date + let wakeTime: Date + let targetSleepHours: Double + let durationStyle: DurationDisplayStyle + /// Set when the activity begins after bedtime has already passed. Before + /// bedtime the card relies on `staleDate` to make the same switch on its + /// own, which `staleDate` can only do for a date still ahead of it. + let isSleeping: Bool + /// How many nights ahead of the last HealthKit-backed recalculation this + /// one is. 0 is tonight's real recommendation; a locally pre-scheduled + /// queue (see `LiveActivityManager`) just repeats those same clock times + /// for the following nights, so a higher count means a longer stretch + /// without a fresh recommendation, and the card nudges more insistently + /// to reopen the app. + var nightsSinceLastSync: Int = 0 + /// The most recent app HealthKit sleep data came from, if that app + /// offers its own deep link. Set once, before bedtime, from whatever + /// wrote the last several nights of data, since the source rarely + /// changes night to night. + var sourceAppLink: BedtimeSourceAppLink? = nil + /// Flipped by `LiveActivityManager.markAwakeIfNeeded()`, which the app + /// calls whenever it gets a chance to run after `wakeTime` — from + /// HealthKit background delivery when new sleep data lands, or a + /// scheduled background refresh task as a backup. `staleDate` only + /// gets one automatic local transition (already spent on wind-down → + /// sleeping), so reaching this phase needs the app to actually run and + /// push a fresh update rather than a second local staleness flip. + var isAwake: Bool = false + } +} diff --git a/Bedtime/Bedtime/Utils/TimeFormatter.swift b/Bedtime/Shared/TimeFormatter.swift similarity index 98% rename from Bedtime/Bedtime/Utils/TimeFormatter.swift rename to Bedtime/Shared/TimeFormatter.swift index 6e8a79e..c04646f 100644 --- a/Bedtime/Bedtime/Utils/TimeFormatter.swift +++ b/Bedtime/Shared/TimeFormatter.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI /// How durations are shown throughout the app. -enum DurationDisplayStyle: String, CaseIterable, Identifiable { +enum DurationDisplayStyle: String, Codable, CaseIterable, Identifiable { /// Compact hours and minutes, e.g. "5h 6m". case hoursAndMinutes /// Decimal hours, e.g. "5.1h" (or "7.25h" when more precision is needed). diff --git a/Bedtime/Shared/TimeInterval.swift b/Bedtime/Shared/TimeInterval.swift new file mode 100644 index 0000000..be9e094 --- /dev/null +++ b/Bedtime/Shared/TimeInterval.swift @@ -0,0 +1,32 @@ +// +// TimeInterval.swift +// Bedtime +// +// Created by Greg Bernstein on 8/13/26. +// + +import Foundation + +extension TimeInterval { + + static func minutes(_ minutes: Int) -> TimeInterval { + TimeInterval(60 * minutes) + } + + static func minutes(_ minutes: Double) -> TimeInterval { + TimeInterval(60 * minutes) + } + + static func hours(_ hours: Int) -> TimeInterval { + TimeInterval(60 * 60 * hours) + } + + static func hours(_ hours: Double) -> TimeInterval { + TimeInterval(60 * 60 * hours) + } + + static func days(_ days: Int) -> TimeInterval { + TimeInterval(24 * 60 * 60 * days) + } + +} diff --git a/README.md b/README.md index 4e54444..45f7faf 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - Considers your normal wake time and sleep goal - Adjusts recommendations when you're in sleep debt - Tap the wake time on the recommendation card to adjust it without opening settings +- Starts a Live Activity with a bedtime countdown and overnight progress on the Lock Screen and Dynamic Island ### 📊 Sleep History - View your recent sleep sessions from HealthKit