From 7ad5ae74639a60195def395bd8a39871f34fdf94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 14:06:08 +0000 Subject: [PATCH 01/70] Add bedtime Live Activity Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 161 +++++++++++++++++ Bedtime/Bedtime/ContentView.swift | 11 +- .../Bedtime/Models/LiveActivityManager.swift | 111 ++++++++++++ .../Views/BedtimeRecommendationCard.swift | 63 ++++++- Bedtime/BedtimeLiveActivity-Info.plist | 13 ++ .../BedtimeLiveActivity.swift | 168 ++++++++++++++++++ .../Shared/BedtimeActivityAttributes.swift | 12 ++ README.md | 1 + 8 files changed, 536 insertions(+), 4 deletions(-) create mode 100644 Bedtime/Bedtime/Models/LiveActivityManager.swift create mode 100644 Bedtime/BedtimeLiveActivity-Info.plist create mode 100644 Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift create mode 100644 Bedtime/Shared/BedtimeActivityAttributes.swift diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 386fa6d..2414742 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -6,8 +6,40 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; + 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, 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 App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed App Extensions */, + ); + name = "Embed App 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; }; + 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BedtimeActivityAttributes.swift; sourceTree = ""; }; + 4C4956450000000000000011 /* BedtimeLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BedtimeLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +48,11 @@ path = Bedtime; sourceTree = ""; }; + 4C4956450000000000000050 /* BedtimeLiveActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BedtimeLiveActivity; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +63,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4C4956450000000000000060 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +77,8 @@ isa = PBXGroup; children = ( 373AAEDF2E91B7E900D8ED84 /* Bedtime */, + 4C4956450000000000000050 /* BedtimeLiveActivity */, + 4C4956450000000000000070 /* Shared */, 373AAEDE2E91B7E900D8ED84 /* Products */, ); sourceTree = ""; @@ -41,10 +87,19 @@ isa = PBXGroup; children = ( 373AAEDD2E91B7E900D8ED84 /* Bedger.app */, + 4C4956450000000000000011 /* BedtimeLiveActivity.appex */, ); name = Products; sourceTree = ""; }; + 4C4956450000000000000070 /* Shared */ = { + isa = PBXGroup; + children = ( + 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */, + ); + path = Shared; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -55,10 +110,12 @@ 373AAED92E91B7E900D8ED84 /* Sources */, 373AAEDA2E91B7E900D8ED84 /* Frameworks */, 373AAEDB2E91B7E900D8ED84 /* Resources */, + 4C4956450000000000000040 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 4C4956450000000000000080 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 373AAEDF2E91B7E900D8ED84 /* Bedtime */, @@ -70,6 +127,28 @@ 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 = ( + 4C4956450000000000000050 /* BedtimeLiveActivity */, + ); + name = BedtimeLiveActivity; + packageProductDependencies = ( + ); + productName = BedtimeLiveActivity; + productReference = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +162,9 @@ 373AAEDC2E91B7E900D8ED84 = { CreatedOnToolsVersion = 26.0; }; + 4C4956450000000000000030 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 373AAED82E91B7E900D8ED84 /* Build configuration list for PBXProject "Bedtime" */; @@ -100,6 +182,7 @@ projectRoot = ""; targets = ( 373AAEDC2E91B7E900D8ED84 /* Bedtime */, + 4C4956450000000000000030 /* BedtimeLiveActivity */, ); }; /* End PBXProject section */ @@ -112,6 +195,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4C49564500000000000000B0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -119,11 +209,28 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4C49564500000000000000A0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */, ); 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; @@ -138,6 +245,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; 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_UIApplicationSceneManifest_Generation = YES; @@ -176,6 +284,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; 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_UIApplicationSceneManifest_Generation = YES; @@ -322,6 +431,49 @@ }; name = Release; }; + 4C49564500000000000000C0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9J44X82D53; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.9.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4C49564500000000000000D0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9J44X82D53; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.9.0; + PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -343,6 +495,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/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index 8806d5a..2be6123 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 + @StateObject private var liveActivityManager = LiveActivityManager() @State private var showingSettings = false @State private var showingError = false @State private var error: Error? @@ -88,7 +89,10 @@ struct ContentView: View { LastNightCard(sleepSessions: lastNightData, goal: userPreferences.sleepGoalHours) } else { - BedtimeRecommendationCard(recommendation: bedtimeRecommendation) + BedtimeRecommendationCard( + recommendation: bedtimeRecommendation, + liveActivityManager: liveActivityManager + ) } SleepBankCard(sleepBank: sleepBank) @@ -106,7 +110,10 @@ struct ContentView: View { } if isBeforeEvening { - BedtimeRecommendationCard(recommendation: bedtimeRecommendation) + BedtimeRecommendationCard( + recommendation: bedtimeRecommendation, + liveActivityManager: liveActivityManager + ) } else { LastNightCard(sleepSessions: lastNightData, goal: userPreferences.sleepGoalHours) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift new file mode 100644 index 0000000..2d00b80 --- /dev/null +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -0,0 +1,111 @@ +import ActivityKit +import Combine +import Foundation + +@MainActor +final class LiveActivityManager: ObservableObject { + @Published private(set) var activeActivityID: String? + @Published private(set) var isWorking = false + @Published private(set) var lastErrorMessage: String? + + var isActivityActive: Bool { + activeActivityID != nil + } + + var areActivitiesEnabled: Bool { + ActivityAuthorizationInfo().areActivitiesEnabled + } + + init() { + activeActivityID = Activity.activities.first?.id + } + + func startOrUpdate(with recommendation: BedtimeRecommendation) async { + guard areActivitiesEnabled else { + lastErrorMessage = "Live Activities are disabled for Bedger in system settings." + return + } + + isWorking = true + lastErrorMessage = nil + defer { isWorking = false } + + let schedule = upcomingSchedule(for: recommendation) + let state = BedtimeActivityAttributes.ContentState( + bedtime: schedule.bedtime, + wakeTime: schedule.wakeTime, + targetSleepHours: recommendation.targetSleepDuration + ) + let content = ActivityContent( + state: state, + staleDate: schedule.wakeTime + ) + + if let activity = currentActivity { + await activity.update(content) + activeActivityID = activity.id + return + } + + do { + let activity = try Activity.request( + attributes: BedtimeActivityAttributes(title: "Tonight’s sleep plan"), + content: content, + pushType: nil + ) + activeActivityID = activity.id + } catch { + lastErrorMessage = error.localizedDescription + } + } + + func end() async { + isWorking = true + lastErrorMessage = nil + defer { isWorking = false } + + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + activeActivityID = nil + } + + private var currentActivity: Activity? { + guard let activeActivityID else { return nil } + return Activity.activities.first { + $0.id == activeActivityID + } + } + + private func upcomingSchedule( + 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 + ) + + let bedtime = calendar.nextDate( + after: now, + matching: bedtimeComponents, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .forward + ) ?? recommendation.recommendedBedtime + let wakeTime = calendar.nextDate( + after: bedtime, + matching: wakeTimeComponents, + matchingPolicy: .nextTime, + repeatedTimePolicy: .first, + direction: .forward + ) ?? recommendation.wakeTime + + return (bedtime, wakeTime) + } +} diff --git a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift index 05b0443..3e467fb 100644 --- a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift +++ b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift @@ -9,6 +9,7 @@ import SwiftUI struct BedtimeRecommendationCard: View { let recommendation: BedtimeRecommendation + @ObservedObject var liveActivityManager: LiveActivityManager var body: some View { CardComponent { @@ -59,8 +60,65 @@ struct BedtimeRecommendationCard: View { .multilineTextAlignment(.leading) .padding(.top, 4) } + + Divider() + + liveActivityControls + } + } + } + } + + @ViewBuilder + private var liveActivityControls: some View { + if liveActivityManager.areActivitiesEnabled { + HStack(spacing: 12) { + Button { + Task { + await liveActivityManager.startOrUpdate(with: recommendation) + } + } 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) } } } @@ -71,7 +129,8 @@ struct BedtimeRecommendationCard: View { recommendedBedtime: Date(), wakeTime: Date(), targetSleepDuration: 8, - reason: "Yo" - ) + reason: "You’re on track for a full night of sleep." + ), + liveActivityManager: LiveActivityManager() ) } diff --git a/Bedtime/BedtimeLiveActivity-Info.plist b/Bedtime/BedtimeLiveActivity-Info.plist new file mode 100644 index 0000000..2bcf53c --- /dev/null +++ b/Bedtime/BedtimeLiveActivity-Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleDisplayName + Bedger Live Activity + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift new file mode 100644 index 0000000..c67be8d --- /dev/null +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -0,0 +1,168 @@ +import ActivityKit +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 + BedtimeLockScreenView(state: context.state) + .activityBackgroundTint(Color.indigo.opacity(0.16)) + .activitySystemActionForegroundColor(.primary) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label("Bedger", systemImage: phase(for: context.state).symbol) + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + phaseTimer(for: context.state) + .font(.headline.monospacedDigit()) + } + + DynamicIslandExpandedRegion(.bottom) { + VStack(spacing: 6) { + HStack { + Text(phase(for: context.state).message) + Spacer() + Text(context.state.wakeTime, style: .time) + .foregroundStyle(.secondary) + } + + ProgressView( + timerInterval: context.state.bedtime...context.state.wakeTime, + countsDown: false + ) + .tint(.indigo) + } + .font(.subheadline) + } + } compactLeading: { + Image(systemName: phase(for: context.state).symbol) + .foregroundStyle(.indigo) + } compactTrailing: { + phaseTimer(for: context.state) + .font(.caption2.monospacedDigit()) + .frame(maxWidth: 48) + } minimal: { + Image(systemName: phase(for: context.state).symbol) + .foregroundStyle(.indigo) + } + .keylineTint(.indigo) + } + } + + private func phase(for state: BedtimeActivityAttributes.ContentState) -> BedtimePhase { + if Date() < state.bedtime { + return .windingDown + } + if Date() < state.wakeTime { + return .sleeping + } + return .complete + } + + @ViewBuilder + private func phaseTimer(for state: BedtimeActivityAttributes.ContentState) -> some View { + if Date() < state.bedtime { + Text(timerInterval: Date()...state.bedtime, countsDown: true) + } else if Date() < state.wakeTime { + Text(timerInterval: Date()...state.wakeTime, countsDown: true) + } else { + Text("Done") + } + } +} + +private struct BedtimeLockScreenView: View { + let state: BedtimeActivityAttributes.ContentState + + private var phase: BedtimePhase { + if Date() < state.bedtime { + return .windingDown + } + if Date() < state.wakeTime { + return .sleeping + } + return .complete + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Bedger", systemImage: phase.symbol) + .font(.headline) + .foregroundStyle(.indigo) + + Spacer() + + Text(phase.message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(phase == .windingDown ? "Bedtime" : "Wake time") + .font(.caption) + .foregroundStyle(.secondary) + Text(phase == .windingDown ? state.bedtime : state.wakeTime, style: .time) + .font(.title2.bold()) + } + + Spacer() + + countdown + .font(.title3.bold().monospacedDigit()) + .foregroundStyle(.indigo) + } + + ProgressView( + timerInterval: state.bedtime...state.wakeTime, + countsDown: false + ) + .tint(.indigo) + } + .padding() + } + + @ViewBuilder + private var countdown: some View { + if Date() < state.bedtime { + Text(timerInterval: Date()...state.bedtime, countsDown: true) + } else if Date() < state.wakeTime { + Text(timerInterval: Date()...state.wakeTime, countsDown: true) + } else { + Text("Good morning") + } + } +} + +private enum BedtimePhase: Equatable { + case windingDown + case sleeping + case complete + + var symbol: String { + switch self { + case .windingDown: "bed.double.fill" + case .sleeping: "moon.zzz.fill" + case .complete: "sun.max.fill" + } + } + + var message: String { + switch self { + case .windingDown: "Wind-down countdown" + case .sleeping: "Sleep window" + case .complete: "Good morning" + } + } +} diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift new file mode 100644 index 0000000..a8a3b1a --- /dev/null +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -0,0 +1,12 @@ +import ActivityKit +import Foundation + +struct BedtimeActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + let bedtime: Date + let wakeTime: Date + let targetSleepHours: Double + } + + let title: String +} diff --git a/README.md b/README.md index 125fdcd..f8ac090 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A native iOS app that helps optimize your sleep by tracking your "sleep bank" an - Personalized bedtime suggestions based on your sleep bank - Considers your normal wake time and sleep goal - Adjusts recommendations when you're in sleep debt +- 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 From 7f0feb5e4a920063d10d59ccaa289253333a1ac2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 14:06:57 +0000 Subject: [PATCH 02/70] Complete Live Activity extension configuration Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 12 ++++++++++++ Bedtime/BedtimeLiveActivity-Info.plist | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 2414742..37da0f4 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -434,12 +434,18 @@ 4C49564500000000000000C0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 9J44X82D53; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -456,12 +462,18 @@ 4C49564500000000000000D0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 9J44X82D53; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Bedtime/BedtimeLiveActivity-Info.plist b/Bedtime/BedtimeLiveActivity-Info.plist index 2bcf53c..88cffec 100644 --- a/Bedtime/BedtimeLiveActivity-Info.plist +++ b/Bedtime/BedtimeLiveActivity-Info.plist @@ -2,8 +2,24 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Bedger Live Activity + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier From f0b319ce2d92120a83d10e4fe89f61e51af91bc9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 14:07:59 +0000 Subject: [PATCH 03/70] Use system-managed Live Activity timers Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 1 + .../BedtimeLiveActivity.swift | 143 ++++++++---------- .../Shared/BedtimeActivityAttributes.swift | 1 + 3 files changed, 63 insertions(+), 82 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 2d00b80..2c8ef56 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -32,6 +32,7 @@ final class LiveActivityManager: ObservableObject { let schedule = upcomingSchedule(for: recommendation) let state = BedtimeActivityAttributes.ContentState( + activityStart: Date(), bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index c67be8d..e963419 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -1,4 +1,5 @@ import ActivityKit +import Foundation import SwiftUI import WidgetKit @@ -18,22 +19,31 @@ struct BedtimeLiveActivity: Widget { } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label("Bedger", systemImage: phase(for: context.state).symbol) + Label("Bedger", systemImage: "bed.double.fill") .font(.headline) } DynamicIslandExpandedRegion(.trailing) { - phaseTimer(for: context.state) + bedtimeCountdown(for: context.state) .font(.headline.monospacedDigit()) } DynamicIslandExpandedRegion(.bottom) { VStack(spacing: 6) { HStack { - Text(phase(for: context.state).message) + Label { + Text(context.state.bedtime, style: .time) + } icon: { + Image(systemName: "moon.fill") + } + Spacer() - Text(context.state.wakeTime, style: .time) - .foregroundStyle(.secondary) + + Label { + Text(context.state.wakeTime, style: .time) + } icon: { + Image(systemName: "sun.max.fill") + } } ProgressView( @@ -45,83 +55,71 @@ struct BedtimeLiveActivity: Widget { .font(.subheadline) } } compactLeading: { - Image(systemName: phase(for: context.state).symbol) + Image(systemName: "bed.double.fill") .foregroundStyle(.indigo) } compactTrailing: { - phaseTimer(for: context.state) + bedtimeCountdown(for: context.state) .font(.caption2.monospacedDigit()) .frame(maxWidth: 48) } minimal: { - Image(systemName: phase(for: context.state).symbol) + Image(systemName: "bed.double.fill") .foregroundStyle(.indigo) } .keylineTint(.indigo) } } - private func phase(for state: BedtimeActivityAttributes.ContentState) -> BedtimePhase { - if Date() < state.bedtime { - return .windingDown - } - if Date() < state.wakeTime { - return .sleeping - } - return .complete - } - - @ViewBuilder - private func phaseTimer(for state: BedtimeActivityAttributes.ContentState) -> some View { - if Date() < state.bedtime { - Text(timerInterval: Date()...state.bedtime, countsDown: true) - } else if Date() < state.wakeTime { - Text(timerInterval: Date()...state.wakeTime, countsDown: true) - } else { - Text("Done") - } + private func bedtimeCountdown( + for state: BedtimeActivityAttributes.ContentState + ) -> Text { + Text( + timerInterval: state.activityStart...state.bedtime, + countsDown: true + ) } } private struct BedtimeLockScreenView: View { let state: BedtimeActivityAttributes.ContentState - private var phase: BedtimePhase { - if Date() < state.bedtime { - return .windingDown - } - if Date() < state.wakeTime { - return .sleeping - } - return .complete - } - var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Label("Bedger", systemImage: phase.symbol) + Label("Tonight’s sleep plan", systemImage: "bed.double.fill") .font(.headline) .foregroundStyle(.indigo) Spacer() - Text(phase.message) - .font(.subheadline) - .foregroundStyle(.secondary) + Text( + timerInterval: state.activityStart...state.bedtime, + countsDown: true + ) + .font(.headline.monospacedDigit()) } - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 2) { - Text(phase == .windingDown ? "Bedtime" : "Wake time") - .font(.caption) - .foregroundStyle(.secondary) - Text(phase == .windingDown ? state.bedtime : state.wakeTime, style: .time) - .font(.title2.bold()) - } + HStack { + scheduleTime( + label: "Bedtime", + date: state.bedtime, + symbol: "moon.fill" + ) Spacer() - countdown - .font(.title3.bold().monospacedDigit()) - .foregroundStyle(.indigo) + Text( + "\(state.targetSleepHours.formatted(.number.precision(.fractionLength(1))))h target" + ) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + scheduleTime( + label: "Wake time", + date: state.wakeTime, + symbol: "sun.max.fill" + ) } ProgressView( @@ -133,36 +131,17 @@ private struct BedtimeLockScreenView: View { .padding() } - @ViewBuilder - private var countdown: some View { - if Date() < state.bedtime { - Text(timerInterval: Date()...state.bedtime, countsDown: true) - } else if Date() < state.wakeTime { - Text(timerInterval: Date()...state.wakeTime, countsDown: true) - } else { - Text("Good morning") - } - } -} - -private enum BedtimePhase: Equatable { - case windingDown - case sleeping - case complete - - var symbol: String { - switch self { - case .windingDown: "bed.double.fill" - case .sleeping: "moon.zzz.fill" - case .complete: "sun.max.fill" - } - } - - var message: String { - switch self { - case .windingDown: "Wind-down countdown" - case .sleeping: "Sleep window" - case .complete: "Good morning" + private func scheduleTime( + label: String, + date: Date, + symbol: String + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Label(label, systemImage: symbol) + .font(.caption) + .foregroundStyle(.secondary) + Text(date, style: .time) + .font(.title3.bold()) } } } diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index a8a3b1a..6d4efc1 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -3,6 +3,7 @@ import Foundation struct BedtimeActivityAttributes: ActivityAttributes { struct ContentState: Codable, Hashable { + let activityStart: Date let bedtime: Date let wakeTime: Date let targetSleepHours: Double From e3f8a231ca33923d78f9ba6a38447b6b598f6245 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 14:21:43 +0000 Subject: [PATCH 04/70] Fix Live Activity extension archive export configuration Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 28 +++++++++++-------- Bedtime/BedtimeLiveActivity-Info.plist | 18 ------------ .../Shared/BedtimeActivityAttributes.swift | 4 ++- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 37da0f4..bb47dcb 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -9,7 +9,7 @@ /* Begin PBXBuildFile section */ 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; - 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -23,15 +23,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 4C4956450000000000000040 /* Embed App Extensions */ = { + 4C4956450000000000000040 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed App Extensions */, + 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */, ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ @@ -110,7 +110,7 @@ 373AAED92E91B7E900D8ED84 /* Sources */, 373AAEDA2E91B7E900D8ED84 /* Frameworks */, 373AAEDB2E91B7E900D8ED84 /* Resources */, - 4C4956450000000000000040 /* Embed App Extensions */, + 4C4956450000000000000040 /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -434,13 +434,14 @@ 4C49564500000000000000C0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 9J44X82D53; - GENERATE_INFOPLIST_FILE = NO; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + INFOPLIST_KEY_CFBundleDisplayName = Bedger; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -450,10 +451,12 @@ PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -462,13 +465,14 @@ 4C49564500000000000000D0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 9J44X82D53; - GENERATE_INFOPLIST_FILE = NO; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + INFOPLIST_KEY_CFBundleDisplayName = Bedger; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -478,9 +482,11 @@ PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; + 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; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Bedtime/BedtimeLiveActivity-Info.plist b/Bedtime/BedtimeLiveActivity-Info.plist index 88cffec..0f118fb 100644 --- a/Bedtime/BedtimeLiveActivity-Info.plist +++ b/Bedtime/BedtimeLiveActivity-Info.plist @@ -2,24 +2,6 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Bedger Live Activity - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 6d4efc1..4d3c425 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -1,7 +1,9 @@ import ActivityKit import Foundation -struct BedtimeActivityAttributes: ActivityAttributes { +// ActivityKit hands this type to the widget extension across isolation +// boundaries, so it must stay off the target's default MainActor isolation. +nonisolated struct BedtimeActivityAttributes: ActivityAttributes { struct ContentState: Codable, Hashable { let activityStart: Date let bedtime: Date From ab8c554ad8ba4dda0adcff16578e79374e844205 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 7 Aug 2026 13:51:20 -0400 Subject: [PATCH 05/70] schemes --- .../xcshareddata/xcschemes/Bedtime.xcscheme | 79 ++++++++++++ .../xcschemes/BedtimeLiveActivity.xcscheme | 115 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/Bedtime.xcscheme create mode 100644 Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme diff --git a/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/Bedtime.xcscheme b/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/Bedtime.xcscheme new file mode 100644 index 0000000..04fd732 --- /dev/null +++ b/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/Bedtime.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme b/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme new file mode 100644 index 0000000..69e6caa --- /dev/null +++ b/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 510a63a54f831a0620ba1f6a07bc052ecb5cfdba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 20:31:38 +0000 Subject: [PATCH 06/70] Show minute-precision countdown in Live Activity and improve legibility Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 6 + .../Bedtime/Models/LiveActivityManager.swift | 14 +- .../Views/BedtimeRecommendationCard.swift | 6 +- .../BedtimeLiveActivity.swift | 210 +++++++++++------- .../Shared/BedtimeActivityAttributes.swift | 2 + .../Utils => Shared}/TimeFormatter.swift | 2 +- 6 files changed, 149 insertions(+), 91 deletions(-) rename Bedtime/{Bedtime/Utils => Shared}/TimeFormatter.swift (98%) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index bb47dcb..9a760cc 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; + 4C4956450000000000000004 /* TimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000012 /* TimeFormatter.swift */; }; + 4C4956450000000000000005 /* TimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000012 /* TimeFormatter.swift */; }; 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -39,6 +41,7 @@ /* Begin PBXFileReference section */ 373AAEDD2E91B7E900D8ED84 /* Bedger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bedger.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BedtimeActivityAttributes.swift; sourceTree = ""; }; + 4C4956450000000000000012 /* TimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeFormatter.swift; sourceTree = ""; }; 4C4956450000000000000011 /* BedtimeLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BedtimeLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -96,6 +99,7 @@ isa = PBXGroup; children = ( 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */, + 4C4956450000000000000012 /* TimeFormatter.swift */, ); path = Shared; sourceTree = ""; @@ -210,6 +214,7 @@ buildActionMask = 2147483647; files = ( 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */, + 4C4956450000000000000004 /* TimeFormatter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -218,6 +223,7 @@ buildActionMask = 2147483647; files = ( 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */, + 4C4956450000000000000005 /* TimeFormatter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 2c8ef56..348bb66 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -20,7 +20,10 @@ final class LiveActivityManager: ObservableObject { activeActivityID = Activity.activities.first?.id } - func startOrUpdate(with recommendation: BedtimeRecommendation) async { + func startOrUpdate( + with recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle + ) async { guard areActivitiesEnabled else { lastErrorMessage = "Live Activities are disabled for Bedger in system settings." return @@ -35,11 +38,14 @@ final class LiveActivityManager: ObservableObject { activityStart: Date(), bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, - targetSleepHours: recommendation.targetSleepDuration + targetSleepHours: recommendation.targetSleepDuration, + durationStyle: durationStyle ) + // Going stale at bedtime is what flips the card from the wind-down + // countdown to the sleeping one while the app is suspended. let content = ActivityContent( state: state, - staleDate: schedule.wakeTime + staleDate: schedule.bedtime ) if let activity = currentActivity { @@ -50,7 +56,7 @@ final class LiveActivityManager: ObservableObject { do { let activity = try Activity.request( - attributes: BedtimeActivityAttributes(title: "Tonight’s sleep plan"), + attributes: BedtimeActivityAttributes(title: "Bedger"), content: content, pushType: nil ) diff --git a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift index 3e467fb..e0cd7bf 100644 --- a/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift +++ b/Bedtime/Bedtime/Views/BedtimeRecommendationCard.swift @@ -10,6 +10,7 @@ import SwiftUI struct BedtimeRecommendationCard: View { let recommendation: BedtimeRecommendation @ObservedObject var liveActivityManager: LiveActivityManager + @Environment(\.durationDisplayStyle) private var durationStyle var body: some View { CardComponent { @@ -75,7 +76,10 @@ struct BedtimeRecommendationCard: View { HStack(spacing: 12) { Button { Task { - await liveActivityManager.startOrUpdate(with: recommendation) + await liveActivityManager.startOrUpdate( + with: recommendation, + durationStyle: durationStyle + ) } } label: { Label( diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index e963419..661e9f6 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -13,135 +13,175 @@ struct BedtimeLiveActivityBundle: WidgetBundle { struct BedtimeLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: BedtimeActivityAttributes.self) { context in - BedtimeLockScreenView(state: context.state) - .activityBackgroundTint(Color.indigo.opacity(0.16)) - .activitySystemActionForegroundColor(.primary) + BedtimeSummaryView( + state: context.state, + phase: BedtimePhase(isStale: context.isStale) + ) + .padding() + .activitySystemActionForegroundColor(.primary) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Label("Bedger", systemImage: "bed.double.fill") - .font(.headline) - } - - DynamicIslandExpandedRegion(.trailing) { - bedtimeCountdown(for: context.state) - .font(.headline.monospacedDigit()) + Label( + context.attributes.title, + systemImage: BedtimePhase(isStale: context.isStale).symbol + ) + .font(.headline) } DynamicIslandExpandedRegion(.bottom) { - VStack(spacing: 6) { - HStack { - Label { - Text(context.state.bedtime, style: .time) - } icon: { - Image(systemName: "moon.fill") - } - - Spacer() - - Label { - Text(context.state.wakeTime, style: .time) - } icon: { - Image(systemName: "sun.max.fill") - } - } - - ProgressView( - timerInterval: context.state.bedtime...context.state.wakeTime, - countsDown: false - ) - .tint(.indigo) - } - .font(.subheadline) + BedtimeSummaryView( + state: context.state, + phase: BedtimePhase(isStale: context.isStale) + ) } } compactLeading: { - Image(systemName: "bed.double.fill") + Image(systemName: BedtimePhase(isStale: context.isStale).symbol) .foregroundStyle(.indigo) } compactTrailing: { - bedtimeCountdown(for: context.state) - .font(.caption2.monospacedDigit()) - .frame(maxWidth: 48) + CountdownText( + state: context.state, + phase: BedtimePhase(isStale: context.isStale), + maxFieldCount: 1 + ) + .lineLimit(1) + .minimumScaleFactor(0.6) } minimal: { - Image(systemName: "bed.double.fill") + Image(systemName: BedtimePhase(isStale: context.isStale).symbol) .foregroundStyle(.indigo) } .keylineTint(.indigo) } } +} + +/// The activity covers two stretches of the night. `staleDate` is set to bedtime, +/// so the system re-renders into the sleeping phase without the app running. +private enum BedtimePhase { + case windDown + case sleeping + + init(isStale: Bool) { + self = isStale ? .sleeping : .windDown + } + + var symbol: String { + switch self { + case .windDown: "bed.double.fill" + case .sleeping: "moon.zzz.fill" + } + } + + var countdownLabel: String { + switch self { + case .windDown: "Bedtime in" + case .sleeping: "Wake in" + } + } + + func target(in state: BedtimeActivityAttributes.ContentState) -> Date { + switch self { + case .windDown: state.bedtime + case .sleeping: state.wakeTime + } + } + + func progressRange( + in state: BedtimeActivityAttributes.ContentState + ) -> ClosedRange { + let start = switch self { + case .windDown: state.activityStart + case .sleeping: state.bedtime + } + let end = target(in: state) + return min(start, end)...max(start, end) + } +} - private func bedtimeCountdown( - for state: BedtimeActivityAttributes.ContentState - ) -> Text { +/// 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. +private struct CountdownText: View { + let state: BedtimeActivityAttributes.ContentState + let phase: BedtimePhase + var maxFieldCount: Int = 2 + + var body: some View { Text( - timerInterval: state.activityStart...state.bedtime, - countsDown: true + .currentDate, + format: .offset( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + maxFieldCount: maxFieldCount, + sign: .never + ) ) } } -private struct BedtimeLockScreenView: View { +private struct BedtimeSummaryView: View { let state: BedtimeActivityAttributes.ContentState + let phase: BedtimePhase + + private var targetText: String { + TimeFormatter.formatHours( + state.targetSleepHours, + style: state.durationStyle, + maxFractionDigits: 2 + ) + } var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("Tonight’s sleep plan", systemImage: "bed.double.fill") - .font(.headline) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: phase.symbol) .foregroundStyle(.indigo) - Spacer() + Text(phase.countdownLabel) - Text( - timerInterval: state.activityStart...state.bedtime, - countsDown: true - ) - .font(.headline.monospacedDigit()) + CountdownText(state: state, phase: phase) + .fontWeight(.semibold) + + Spacer(minLength: 0) + } + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + + ProgressView(timerInterval: phase.progressRange(in: state), countsDown: false) { + EmptyView() + } currentValueLabel: { + EmptyView() } + .tint(.indigo) - HStack { - scheduleTime( - label: "Bedtime", - date: state.bedtime, - symbol: "moon.fill" - ) + HStack(alignment: .firstTextBaseline) { + scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) - Spacer() + Spacer(minLength: 8) - Text( - "\(state.targetSleepHours.formatted(.number.precision(.fractionLength(1))))h target" - ) - .font(.caption) - .foregroundStyle(.secondary) + Text("\(targetText) target") + .font(.caption) + .foregroundStyle(.secondary) - Spacer() + Spacer(minLength: 8) - scheduleTime( - label: "Wake time", - date: state.wakeTime, - symbol: "sun.max.fill" - ) + scheduleTime(label: "Wake", date: state.wakeTime, alignment: .trailing) } - - ProgressView( - timerInterval: state.bedtime...state.wakeTime, - countsDown: false - ) - .tint(.indigo) } - .padding() } private func scheduleTime( label: String, date: Date, - symbol: String + alignment: HorizontalAlignment ) -> some View { - VStack(alignment: .leading, spacing: 2) { - Label(label, systemImage: symbol) + VStack(alignment: alignment, spacing: 1) { + Text(label) .font(.caption) .foregroundStyle(.secondary) - Text(date, style: .time) - .font(.title3.bold()) + + Text(TimeFormatter.formatTimeOfDay(date)) + .font(.headline) } } } diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 4d3c425..c80e656 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -5,10 +5,12 @@ import Foundation // boundaries, so it must stay off the target's default MainActor isolation. nonisolated struct BedtimeActivityAttributes: ActivityAttributes { 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 } let title: String 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). From 547be9007b0841f063fbfe2f9a79d21b81bfe769 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 7 Aug 2026 20:44:43 +0000 Subject: [PATCH 07/70] Use DateReference for Live Activity countdown phrasing Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 661e9f6..1212ac5 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -41,8 +41,7 @@ struct BedtimeLiveActivity: Widget { } compactTrailing: { CountdownText( state: context.state, - phase: BedtimePhase(isStale: context.isStale), - maxFieldCount: 1 + phase: BedtimePhase(isStale: context.isStale) ) .lineLimit(1) .minimumScaleFactor(0.6) @@ -72,10 +71,11 @@ private enum BedtimePhase { } } + /// `DateReference` supplies its own "in …", so this is only the subject. var countdownLabel: String { switch self { - case .windDown: "Bedtime in" - case .sleeping: "Wake in" + case .windDown: "Bedtime" + case .sleeping: "Wake" } } @@ -100,19 +100,21 @@ private enum BedtimePhase { /// 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 maxFieldCount: Int = 2 var body: some View { Text( .currentDate, - format: .offset( + format: .reference( to: phase.target(in: state), allowedFields: [.hour, .minute], - maxFieldCount: maxFieldCount, - sign: .never + thresholdField: .hour ) ) } @@ -132,9 +134,10 @@ private struct BedtimeSummaryView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 6) { + HStack(spacing: 4) { Image(systemName: phase.symbol) .foregroundStyle(.indigo) + .padding(.trailing, 2) Text(phase.countdownLabel) From 34f1e383a75ea490d8667e185684520c2014635b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 02:32:16 +0000 Subject: [PATCH 08/70] Keep the compact Dynamic Island countdown narrow Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 1212ac5..d2d98fd 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -41,10 +41,14 @@ struct BedtimeLiveActivity: Widget { } compactTrailing: { CountdownText( state: context.state, - phase: BedtimePhase(isStale: context.isStale) + phase: BedtimePhase(isStale: context.isStale), + style: .compact ) .lineLimit(1) - .minimumScaleFactor(0.6) + .minimumScaleFactor(0.5) + // Without a cap the compact region grows to fit its text and + // stretches the whole pill. + .frame(maxWidth: 56) } minimal: { Image(systemName: BedtimePhase(isStale: context.isStale).symbol) .foregroundStyle(.indigo) @@ -105,18 +109,39 @@ private enum BedtimePhase { /// "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 { + enum Style { + /// "in 2 hours", which reads as a sentence next to its subject. + case phrase + /// "2 hours", dropping the preposition where width is scarce. + case compact + } + let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase + var style: Style = .phrase var body: some View { - Text( - .currentDate, - format: .reference( - to: phase.target(in: state), - allowedFields: [.hour, .minute], - thresholdField: .hour + switch style { + case .phrase: + Text( + .currentDate, + format: .reference( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + thresholdField: .hour + ) ) - ) + case .compact: + Text( + .currentDate, + format: .offset( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + maxFieldCount: 1, + sign: .never + ) + ) + } } } From d991fb1cacef9873b62c3ba7395f449cab3d51e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:07:11 +0000 Subject: [PATCH 09/70] Start the bedtime activity automatically and label the compact pill Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 2 + Bedtime/Bedtime/ContentView.swift | 22 ++++++- .../Intents/BedtimeActivityIntents.swift | 52 +++++++++++++++ Bedtime/Bedtime/Models/BedtimePlanStore.swift | 65 +++++++++++++++++++ .../Bedtime/Models/LiveActivityManager.swift | 54 ++++++++++++++- .../BedtimeLiveActivity.swift | 18 ++++- 6 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift create mode 100644 Bedtime/Bedtime/Models/BedtimePlanStore.swift diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 358a686..5c1c5a9 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -21,6 +21,8 @@ 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 = 30 * 60 /// 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 66f7cca..1093aa2 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -15,7 +15,7 @@ struct ContentView: View { @Query private var preferences: [UserPreferences] @StateObject private var sourcePreferences: SourcePreferences @StateObject private var healthKitManager: HealthKitManager - @StateObject private var liveActivityManager = LiveActivityManager() + @ObservedObject private var liveActivityManager = LiveActivityManager.shared @State private var showingSettings = false @State private var showingError = false @State private var error: Error? @@ -224,15 +224,33 @@ 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() + } } } + + /// 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 + ) + } } #Preview { diff --git a/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift new file mode 100644 index 0000000..f73a024 --- /dev/null +++ b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift @@ -0,0 +1,52 @@ +// +// 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( + "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("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..37e998f --- /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 = 7 * 24 * 60 * 60 + + 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/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index eb4a950..749ef8a 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -4,6 +4,10 @@ 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? @@ -49,7 +53,9 @@ final class LiveActivityManager: ObservableObject { let schedule = upcomingSchedule(for: recommendation) let state = BedtimeActivityAttributes.ContentState( - activityStart: Date(), + // Updating an existing activity keeps its original start so the + // wind-down bar doesn't jump back to empty. + activityStart: currentActivity?.content.state.activityStart ?? Date(), bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, @@ -80,6 +86,42 @@ final class LiveActivityManager: ObservableObject { } } + /// 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() + ) async { + await endFinishedActivities(now: now) + + guard isSupported, areActivitiesEnabled else { return } + + let schedule = upcomingSchedule(for: recommendation, now: now) + // 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 >= schedule.bedtime.addingTimeInterval(-leadTime) else { return } + + await startOrUpdate(with: recommendation, durationStyle: durationStyle) + } + + /// 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 @@ -91,6 +133,16 @@ final class LiveActivityManager: ObservableObject { 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 + } + } + } + private var currentActivity: Activity? { guard let activeActivityID else { return nil } return Activity.activities.first { diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index d2d98fd..0b0b6da 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -36,8 +36,13 @@ struct BedtimeLiveActivity: Widget { ) } } compactLeading: { - Image(systemName: BedtimePhase(isStale: context.isStale).symbol) - .foregroundStyle(.indigo) + let phase = BedtimePhase(isStale: context.isStale) + HStack(spacing: 3) { + Image(systemName: phase.symbol) + Text(phase.compactLabel) + } + .foregroundStyle(.indigo) + .lineLimit(1) } compactTrailing: { CountdownText( state: context.state, @@ -83,6 +88,15 @@ private enum BedtimePhase { } } + /// 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" + } + } + func target(in state: BedtimeActivityAttributes.ContentState) -> Date { switch self { case .windDown: state.bedtime From d192b17bd84ee1ae8c4cf170bc4258d691a7cf4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:19:58 +0000 Subject: [PATCH 10/70] Match the optional IntentDescription requirement Co-authored-by: Greg --- Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift index f73a024..49469eb 100644 --- a/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift +++ b/Bedtime/Bedtime/Intents/BedtimeActivityIntents.swift @@ -12,7 +12,7 @@ import AppIntents /// 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( + static let description: IntentDescription? = IntentDescription( "Shows tonight's bedtime countdown on the Lock Screen and in the Dynamic Island." ) @@ -24,7 +24,9 @@ struct StartBedtimeCountdownIntent: LiveActivityIntent { struct EndBedtimeCountdownIntent: LiveActivityIntent { static let title: LocalizedStringResource = "End Bedtime Countdown" - static let description = IntentDescription("Removes tonight's bedtime countdown.") + static let description: IntentDescription? = IntentDescription( + "Removes tonight's bedtime countdown." + ) func perform() async throws -> some IntentResult { await LiveActivityManager.shared.end() From 4427f6db687e0c29abdcf98b955cc343fa2da8f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 05:45:07 +0000 Subject: [PATCH 11/70] Read the lead-time constant inside the isolated method body Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 749ef8a..d7f89d8 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -92,13 +92,16 @@ final class LiveActivityManager: ObservableObject { func syncWithSchedule( recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle, - leadTime: TimeInterval = Constants.liveActivityLeadTime, now: Date = Date() ) async { await endFinishedActivities(now: now) guard isSupported, areActivitiesEnabled else { return } + // Read in the body rather than as a default argument: default values are + // evaluated in a nonisolated context, and `Constants` picks up the + // module's MainActor isolation. + let leadTime = Constants.liveActivityLeadTime let schedule = upcomingSchedule(for: recommendation, now: now) // Once bedtime passes, the next match is tomorrow's, so this only opens // the window ahead of tonight's bedtime. An activity already running From 1d9bed8404bbfc8a7c8084cfd5f60d6a41499c66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 05:55:10 +0000 Subject: [PATCH 12/70] Make Constants a nonisolated enum Co-authored-by: Greg --- Bedtime/Bedtime/Constants.swift | 5 ++++- Bedtime/Bedtime/Models/LiveActivityManager.swift | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 5c1c5a9..ae3392c 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -8,7 +8,10 @@ import Foundation import SwiftUI -class Constants { +/// Pure configuration with no state to protect, so it stays off the module's +/// default MainActor isolation and can be read from any context, including +/// default argument expressions. +nonisolated enum Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 static let sleepHistoryDays = 30 diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index d7f89d8..749ef8a 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -92,16 +92,13 @@ final class LiveActivityManager: ObservableObject { func syncWithSchedule( recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle, + leadTime: TimeInterval = Constants.liveActivityLeadTime, now: Date = Date() ) async { await endFinishedActivities(now: now) guard isSupported, areActivitiesEnabled else { return } - // Read in the body rather than as a default argument: default values are - // evaluated in a nonisolated context, and `Constants` picks up the - // module's MainActor isolation. - let leadTime = Constants.liveActivityLeadTime let schedule = upcomingSchedule(for: recommendation, now: now) // Once bedtime passes, the next match is tomorrow's, so this only opens // the window ahead of tonight's bedtime. An activity already running From 6bf7c39cace3da42b3cb1aea609e15c745b0b407 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 05:57:55 +0000 Subject: [PATCH 13/70] Schedule the activity to start itself at wind-down on iOS 26 Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 749ef8a..4238ed9 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -69,9 +69,15 @@ final class LiveActivityManager: ObservableObject { ) if let activity = currentActivity { - await activity.update(content) - activeActivityID = activity.id - return + if activity.activityState == .active { + await activity.update(content) + activeActivityID = activity.id + return + } + // Anything else is scheduled but not on screen yet. Clear it so this + // request shows up now rather than silently editing tonight's plan. + await activity.end(nil, dismissalPolicy: .immediate) + activeActivityID = nil } do { @@ -100,12 +106,69 @@ final class LiveActivityManager: ObservableObject { guard isSupported, areActivitiesEnabled else { return } let schedule = upcomingSchedule(for: recommendation, now: now) + 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 >= schedule.bedtime.addingTimeInterval(-leadTime) else { return } + guard now < windowOpens else { + await startOrUpdate(with: recommendation, durationStyle: durationStyle) + return + } - await startOrUpdate(with: recommendation, durationStyle: durationStyle) + if #available(iOS 26, *) { + await scheduleActivity( + recommendation: recommendation, + durationStyle: durationStyle, + schedule: schedule, + startingAt: windowOpens + ) + } + } + + /// Hands the start over to the system so the card appears at the wind-down + /// time on its own. This is the only fully local way to get there: starting + /// one from a background wake throws `ActivityAuthorizationError.visibility`, + /// so every other route needs either a push server or a user-triggered intent. + @available(iOS 26, *) + private func scheduleActivity( + recommendation: BedtimeRecommendation, + durationStyle: DurationDisplayStyle, + schedule: (bedtime: Date, wakeTime: Date), + startingAt start: Date + ) async { + for activity in Activity.activities { + guard activity.activityState == .pending else { continue } + // Tonight is already queued up. + guard activity.content.state.bedtime != schedule.bedtime else { return } + await activity.end(nil, dismissalPolicy: .immediate) + } + + let state = BedtimeActivityAttributes.ContentState( + activityStart: start, + bedtime: schedule.bedtime, + wakeTime: schedule.wakeTime, + targetSleepHours: recommendation.targetSleepDuration, + durationStyle: durationStyle + ) + + do { + let activity = try Activity.request( + attributes: BedtimeActivityAttributes(title: "Bedger"), + content: ActivityContent(state: state, staleDate: schedule.bedtime), + pushType: nil, + style: .standard, + alertConfiguration: AlertConfiguration( + title: "Time to wind down", + body: "Bedtime is coming up.", + sound: .default + ), + start: start + ) + activeActivityID = activity.id + } catch { + lastErrorMessage = error.localizedDescription + } } /// Starts tonight's activity from the last saved plan, for entry points that From c2f50db1955d04c624b8aab4f5bd20d4c606d56b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 06:45:19 +0000 Subject: [PATCH 14/70] Resolve the night already underway instead of tomorrow's bedtime Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 4238ed9..d8f8bbd 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -227,20 +227,35 @@ final class LiveActivityManager: ObservableObject { from: recommendation.wakeTime ) - let bedtime = calendar.nextDate( - after: now, - matching: bedtimeComponents, - matchingPolicy: .nextTime, - repeatedTimePolicy: .first, - direction: .forward - ) ?? recommendation.recommendedBedtime - let wakeTime = calendar.nextDate( - after: bedtime, - matching: wakeTimeComponents, - matchingPolicy: .nextTime, - repeatedTimePolicy: .first, - direction: .forward - ) ?? 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) } From 2c1043c1912052cc2ca9e80597e5d99f2d5b6a8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 06:53:45 +0000 Subject: [PATCH 15/70] Keep staleDate in the future and carry the phase in content state Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 20 ++++++++++++------- .../BedtimeLiveActivity.swift | 18 +++++++++-------- .../Shared/BedtimeActivityAttributes.swift | 4 ++++ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index d8f8bbd..6248549 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -51,21 +51,25 @@ final class LiveActivityManager: ObservableObject { lastErrorMessage = nil defer { isWorking = false } - let schedule = upcomingSchedule(for: recommendation) + let now = Date() + let schedule = upcomingSchedule(for: recommendation, now: now) + let isSleeping = now >= schedule.bedtime let state = BedtimeActivityAttributes.ContentState( // Updating an existing activity keeps its original start so the // wind-down bar doesn't jump back to empty. - activityStart: currentActivity?.content.state.activityStart ?? Date(), + activityStart: currentActivity?.content.state.activityStart ?? now, bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, - durationStyle: durationStyle + durationStyle: durationStyle, + isSleeping: isSleeping ) - // Going stale at bedtime is what flips the card from the wind-down - // countdown to the sleeping one while the app is suspended. + // 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: schedule.bedtime + staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) if let activity = currentActivity { @@ -149,7 +153,9 @@ final class LiveActivityManager: ObservableObject { bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, - durationStyle: durationStyle + durationStyle: durationStyle, + // Scheduled activities always begin ahead of bedtime. + isSleeping: false ) do { diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 0b0b6da..93dc531 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -15,7 +15,7 @@ struct BedtimeLiveActivity: Widget { ActivityConfiguration(for: BedtimeActivityAttributes.self) { context in BedtimeSummaryView( state: context.state, - phase: BedtimePhase(isStale: context.isStale) + phase: BedtimePhase(context) ) .padding() .activitySystemActionForegroundColor(.primary) @@ -24,7 +24,7 @@ struct BedtimeLiveActivity: Widget { DynamicIslandExpandedRegion(.leading) { Label( context.attributes.title, - systemImage: BedtimePhase(isStale: context.isStale).symbol + systemImage: BedtimePhase(context).symbol ) .font(.headline) } @@ -32,11 +32,11 @@ struct BedtimeLiveActivity: Widget { DynamicIslandExpandedRegion(.bottom) { BedtimeSummaryView( state: context.state, - phase: BedtimePhase(isStale: context.isStale) + phase: BedtimePhase(context) ) } } compactLeading: { - let phase = BedtimePhase(isStale: context.isStale) + let phase = BedtimePhase(context) HStack(spacing: 3) { Image(systemName: phase.symbol) Text(phase.compactLabel) @@ -46,7 +46,7 @@ struct BedtimeLiveActivity: Widget { } compactTrailing: { CountdownText( state: context.state, - phase: BedtimePhase(isStale: context.isStale), + phase: BedtimePhase(context), style: .compact ) .lineLimit(1) @@ -55,7 +55,7 @@ struct BedtimeLiveActivity: Widget { // stretches the whole pill. .frame(maxWidth: 56) } minimal: { - Image(systemName: BedtimePhase(isStale: context.isStale).symbol) + Image(systemName: BedtimePhase(context).symbol) .foregroundStyle(.indigo) } .keylineTint(.indigo) @@ -69,8 +69,10 @@ private enum BedtimePhase { case windDown case sleeping - init(isStale: Bool) { - self = isStale ? .sleeping : .windDown + init(_ context: ActivityViewContext) { + // 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 { diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index c80e656..09ed306 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -11,6 +11,10 @@ nonisolated struct BedtimeActivityAttributes: ActivityAttributes { 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 } let title: String From dc153b1a6da39d0346cbd63706469480014d4803 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 02:29:07 +0000 Subject: [PATCH 16/70] Show the Live Activity target to a single decimal Co-authored-by: Greg --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 93dc531..841b665 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -166,11 +166,9 @@ private struct BedtimeSummaryView: View { let phase: BedtimePhase private var targetText: String { - TimeFormatter.formatHours( - state.targetSleepHours, - style: state.durationStyle, - maxFractionDigits: 2 - ) + // 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 { From ae26d3796429389321f458995f9412d5c0bec93d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 02:31:48 +0000 Subject: [PATCH 17/70] Show progress in the minimal Dynamic Island presentation Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 841b665..b91b837 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -55,8 +55,20 @@ struct BedtimeLiveActivity: Widget { // stretches the whole pill. .frame(maxWidth: 56) } minimal: { - Image(systemName: BedtimePhase(context).symbol) - .foregroundStyle(.indigo) + // A second app's activity forces both into this presentation, and + // it is too small for the countdown text. A ring keeps the + // remaining time glanceable and updates without the app running. + let phase = BedtimePhase(context) + ProgressView( + timerInterval: phase.progressRange(in: context.state), + countsDown: false + ) { + EmptyView() + } currentValueLabel: { + Image(systemName: phase.symbol) + } + .progressViewStyle(.circular) + .tint(.indigo) } .keylineTint(.indigo) } From 7bd31873e1e4072590df97ba20994d37ffb5e12e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 02:45:36 +0000 Subject: [PATCH 18/70] Use the clock-pattern countdown in the minimal presentation Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index b91b837..2ece87d 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -55,20 +55,17 @@ struct BedtimeLiveActivity: Widget { // stretches the whole pill. .frame(maxWidth: 56) } minimal: { - // A second app's activity forces both into this presentation, and - // it is too small for the countdown text. A ring keeps the - // remaining time glanceable and updates without the app running. - let phase = BedtimePhase(context) - ProgressView( - timerInterval: phase.progressRange(in: context.state), - countsDown: false - ) { - EmptyView() - } currentValueLabel: { - Image(systemName: phase.symbol) - } - .progressViewStyle(.circular) - .tint(.indigo) + // A second app's activity forces both into this presentation, + // which is far too small for "in 2 hours". The clock pattern is + // the only compact numeric form that keeps counting on its own. + Text( + timerInterval: BedtimePhase(context).progressRange(in: context.state), + countsDown: true + ) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.5) + .foregroundStyle(.indigo) } .keylineTint(.indigo) } From 215d54f2393ee185231f07ad804560285f3c18d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 02:48:02 +0000 Subject: [PATCH 19/70] Return the minimal presentation to a ring Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 2ece87d..883bd37 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -56,16 +56,20 @@ struct BedtimeLiveActivity: Widget { .frame(maxWidth: 56) } minimal: { // A second app's activity forces both into this presentation, - // which is far too small for "in 2 hours". The clock pattern is - // the only compact numeric form that keeps counting on its own. - Text( - timerInterval: BedtimePhase(context).progressRange(in: context.state), - countsDown: true - ) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.5) - .foregroundStyle(.indigo) + // which is too small for "in 2 hours". Every compact numeric form + // the system keeps updating carries ticking seconds, so this + // shows the same progress as a ring instead. + let phase = BedtimePhase(context) + ProgressView( + timerInterval: phase.progressRange(in: context.state), + countsDown: false + ) { + EmptyView() + } currentValueLabel: { + Image(systemName: phase.symbol) + } + .progressViewStyle(.circular) + .tint(.indigo) } .keylineTint(.indigo) } From e780093c1cae4861eb323a0a1b1dad2c99ec88b9 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 19:51:37 -0700 Subject: [PATCH 20/70] convert to folder --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 32 +++++++---------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 7478e93..49d64b3 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -7,10 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; - 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */; }; - 4C4956450000000000000004 /* TimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000012 /* TimeFormatter.swift */; }; - 4C4956450000000000000005 /* TimeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000012 /* TimeFormatter.swift */; }; 4C4956450000000000000003 /* BedtimeLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C4956450000000000000011 /* BedtimeLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -40,12 +36,15 @@ /* Begin PBXFileReference section */ 373AAEDD2E91B7E900D8ED84 /* Bedger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bedger.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BedtimeActivityAttributes.swift; sourceTree = ""; }; - 4C4956450000000000000012 /* TimeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeFormatter.swift; sourceTree = ""; }; 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; @@ -81,7 +80,7 @@ children = ( 373AAEDF2E91B7E900D8ED84 /* Bedtime */, 4C4956450000000000000050 /* BedtimeLiveActivity */, - 4C4956450000000000000070 /* Shared */, + 371FB5C6302D689B005F6931 /* Shared */, 373AAEDE2E91B7E900D8ED84 /* Products */, ); sourceTree = ""; @@ -95,15 +94,6 @@ name = Products; sourceTree = ""; }; - 4C4956450000000000000070 /* Shared */ = { - isa = PBXGroup; - children = ( - 4C4956450000000000000010 /* BedtimeActivityAttributes.swift */, - 4C4956450000000000000012 /* TimeFormatter.swift */, - ); - path = Shared; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -122,6 +112,7 @@ 4C4956450000000000000080 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 371FB5C6302D689B005F6931 /* Shared */, 373AAEDF2E91B7E900D8ED84 /* Bedtime */, ); name = Bedtime; @@ -144,6 +135,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + 371FB5C6302D689B005F6931 /* Shared */, 4C4956450000000000000050 /* BedtimeLiveActivity */, ); name = BedtimeLiveActivity; @@ -213,8 +205,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4C4956450000000000000001 /* BedtimeActivityAttributes.swift in Sources */, - 4C4956450000000000000004 /* TimeFormatter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -222,8 +212,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4C4956450000000000000002 /* BedtimeActivityAttributes.swift in Sources */, - 4C4956450000000000000005 /* TimeFormatter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -251,9 +239,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSSupportsLiveActivities = YES; 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; @@ -290,9 +278,9 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = "com.burnsides.bedtime.background-task"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_NSSupportsLiveActivities = YES; 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; From 656b3e15a29c0c6e094154fb61ad8feecfc62aec Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:02:03 -0700 Subject: [PATCH 21/70] consolidate some settings --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 59 ++++++----------------- 1 file changed, 16 insertions(+), 43 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 49d64b3..2612a57 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -232,11 +232,7 @@ 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."; @@ -252,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; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -271,11 +261,7 @@ 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."; @@ -291,14 +277,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; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -338,6 +318,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; @@ -357,14 +338,21 @@ 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; + MARKETING_VERSION = 0.9.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; }; name = Debug; }; @@ -402,6 +390,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; @@ -415,12 +404,19 @@ 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; + MARKETING_VERSION = 0.9.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; VALIDATE_PRODUCT = YES; }; name = Release; @@ -428,29 +424,17 @@ 4C49564500000000000000C0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9J44X82D53; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Bedger; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -459,28 +443,17 @@ 4C49564500000000000000D0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9J44X82D53; - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "BedtimeLiveActivity-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Bedger; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 0.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - 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; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; From 78bcb261fa99618ee1a3b3eb46a6b2d238591290 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:07:59 -0700 Subject: [PATCH 22/70] remove metal settings --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 2612a57..8e0c683 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -342,8 +342,6 @@ IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 0.9.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -408,8 +406,6 @@ IPHONEOS_DEPLOYMENT_TARGET = 17.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 0.9.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; From 73dbe5753ba3d5602abb26d34ed5452443a77d30 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:09:13 -0700 Subject: [PATCH 23/70] swift version --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 8e0c683..1e2bb44 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -250,7 +250,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime; PRODUCT_NAME = Bedger; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -279,7 +278,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime; PRODUCT_NAME = Bedger; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -351,6 +349,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -413,6 +412,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Release; @@ -431,7 +431,6 @@ PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -450,7 +449,6 @@ PRODUCT_BUNDLE_IDENTIFIER = com.burnsides.bedtime.liveactivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; From 8f22fbf1fa8d3eb05ca8a6600a731c80c9cd8986 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:09:39 -0700 Subject: [PATCH 24/70] recommended settings --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 1e2bb44..73ea671 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -153,7 +153,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2660; TargetAttributes = { 373AAEDC2E91B7E900D8ED84 = { CreatedOnToolsVersion = 26.0; From d4018681f688e31a004a574da139b921a365aaf4 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:40:47 -0700 Subject: [PATCH 25/70] remove comment --- Bedtime/Bedtime/Constants.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index ae3392c..9eb6653 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -8,9 +8,6 @@ import Foundation import SwiftUI -/// Pure configuration with no state to protect, so it stays off the module's -/// default MainActor isolation and can be read from any context, including -/// default argument expressions. nonisolated enum Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 From 14c728fb4c28fc48fac25718ae4766a5e75a2886 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:20:41 -0700 Subject: [PATCH 26/70] swift 6 --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 4 ++-- Bedtime/Bedtime/Models/HealthKitManager.swift | 4 ++++ Bedtime/Bedtime/Utils/SleepWindow.swift | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 73ea671..36d697b 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -349,7 +349,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -412,7 +412,7 @@ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/Bedtime/Bedtime/Models/HealthKitManager.swift b/Bedtime/Bedtime/Models/HealthKitManager.swift index ef14cd0..1af4dc3 100644 --- a/Bedtime/Bedtime/Models/HealthKitManager.swift +++ b/Bedtime/Bedtime/Models/HealthKitManager.swift @@ -164,6 +164,10 @@ class HealthKitManager: ObservableObject { // actor to touch `errorMessage`/`loadSleepData`. Always call // `completionHandler()` so HealthKit releases its background assertion and // stops retrying the notification. + // + // The handler type isn't Sendable, but HealthKit allows calling it from + // any thread, so it's safe to move onto the main actor. + nonisolated(unsafe) let completionHandler = completionHandler Task { @MainActor in defer { completionHandler() } guard let self else { return } diff --git a/Bedtime/Bedtime/Utils/SleepWindow.swift b/Bedtime/Bedtime/Utils/SleepWindow.swift index f9c6551..001fac8 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. -struct SleepWindow { +nonisolated 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 From 8747cbf710f7eeabed9f0fe452eb3464bda20d02 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:35:21 -0700 Subject: [PATCH 27/70] fix isolation/sendable error --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 6248549..e4f87d1 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -57,7 +57,7 @@ final class LiveActivityManager: ObservableObject { let state = BedtimeActivityAttributes.ContentState( // Updating an existing activity keeps its original start so the // wind-down bar doesn't jump back to empty. - activityStart: currentActivity?.content.state.activityStart ?? now, + activityStart: Self.activity(withID: activeActivityID)?.content.state.activityStart ?? now, bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, @@ -72,10 +72,10 @@ final class LiveActivityManager: ObservableObject { staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) - if let activity = currentActivity { + if let activity = Self.activity(withID: activeActivityID) { if activity.activityState == .active { - await activity.update(content) activeActivityID = activity.id + await activity.update(content) return } // Anything else is scheduled but not on screen yet. Clear it so this @@ -212,10 +212,12 @@ final class LiveActivityManager: ObservableObject { } } - private var currentActivity: Activity? { - guard let activeActivityID else { return 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 == activeActivityID + $0.id == id } } From 0b9ca8e93cab94a63e5192553738f4fdcb44174b Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:40:34 -0700 Subject: [PATCH 28/70] no op --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index e4f87d1..2e956b0 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -74,7 +74,6 @@ final class LiveActivityManager: ObservableObject { if let activity = Self.activity(withID: activeActivityID) { if activity.activityState == .active { - activeActivityID = activity.id await activity.update(content) return } From 6bd41ae1101d491843804f22dd7567ae7383ae7f Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:47:03 -0700 Subject: [PATCH 29/70] previews --- .../BedtimeLiveActivity.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 883bd37..fa2e245 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -240,3 +240,29 @@ private struct BedtimeSummaryView: View { } } } +#Preview("Lock Screen", as: .content, using: BedtimeActivityAttributes(title: "Bedger")) { + BedtimeLiveActivity() +} contentStates: { + let now = Date() + + // Wind-down: bedtime is still ahead. + BedtimeActivityAttributes.ContentState( + activityStart: now.addingTimeInterval(-30 * 60), + bedtime: now.addingTimeInterval(2 * 60 * 60), + wakeTime: now.addingTimeInterval(10 * 60 * 60), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: false + ) + + // Sleeping: bedtime has passed, counting down to wake. + BedtimeActivityAttributes.ContentState( + activityStart: now.addingTimeInterval(-3 * 60 * 60), + bedtime: now.addingTimeInterval(-60 * 60), + wakeTime: now.addingTimeInterval(7 * 60 * 60), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: true + ) +} + From a816d08e76c52780694389c676b82ee41dcc8541 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 20:49:28 -0700 Subject: [PATCH 30/70] dynamic island, too --- .../BedtimeLiveActivity.swift | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index fa2e245..e75f8b8 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -240,29 +240,59 @@ private struct BedtimeSummaryView: View { } } } +extension BedtimeActivityAttributes.ContentState { + /// Wind-down: bedtime is still ahead. + fileprivate static var windDownSample: Self { + let now = Date() + return Self( + activityStart: now.addingTimeInterval(-30 * 60), + bedtime: now.addingTimeInterval(2 * 60 * 60), + wakeTime: now.addingTimeInterval(10 * 60 * 60), + 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(-3 * 60 * 60), + bedtime: now.addingTimeInterval(-60 * 60), + wakeTime: now.addingTimeInterval(7 * 60 * 60), + targetSleepHours: 8, + durationStyle: .hoursAndMinutes, + isSleeping: true + ) + } +} + #Preview("Lock Screen", as: .content, using: BedtimeActivityAttributes(title: "Bedger")) { BedtimeLiveActivity() } contentStates: { - let now = Date() - - // Wind-down: bedtime is still ahead. - BedtimeActivityAttributes.ContentState( - activityStart: now.addingTimeInterval(-30 * 60), - bedtime: now.addingTimeInterval(2 * 60 * 60), - wakeTime: now.addingTimeInterval(10 * 60 * 60), - targetSleepHours: 8, - durationStyle: .hoursAndMinutes, - isSleeping: false - ) - - // Sleeping: bedtime has passed, counting down to wake. - BedtimeActivityAttributes.ContentState( - activityStart: now.addingTimeInterval(-3 * 60 * 60), - bedtime: now.addingTimeInterval(-60 * 60), - wakeTime: now.addingTimeInterval(7 * 60 * 60), - targetSleepHours: 8, - durationStyle: .hoursAndMinutes, - isSleeping: true - ) + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample +} + +#Preview("Island Expanded", as: .dynamicIsland(.expanded), using: BedtimeActivityAttributes(title: "Bedger")) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample +} + +#Preview("Island Compact", as: .dynamicIsland(.compact), using: BedtimeActivityAttributes(title: "Bedger")) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample +} + +#Preview("Island Minimal", as: .dynamicIsland(.minimal), using: BedtimeActivityAttributes(title: "Bedger")) { + BedtimeLiveActivity() +} contentStates: { + BedtimeActivityAttributes.ContentState.windDownSample + BedtimeActivityAttributes.ContentState.sleepingSample } From 876a3d9f35dedacff7d0c0c7889fa226362b07eb Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 21:14:42 -0700 Subject: [PATCH 31/70] count up? --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index e75f8b8..a263006 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -62,7 +62,7 @@ struct BedtimeLiveActivity: Widget { let phase = BedtimePhase(context) ProgressView( timerInterval: phase.progressRange(in: context.state), - countsDown: false + countsDown: phase == .windDown ) { EmptyView() } currentValueLabel: { @@ -202,7 +202,7 @@ private struct BedtimeSummaryView: View { .lineLimit(1) .minimumScaleFactor(0.7) - ProgressView(timerInterval: phase.progressRange(in: state), countsDown: false) { + ProgressView(timerInterval: phase.progressRange(in: state), countsDown: phase == .windDown) { EmptyView() } currentValueLabel: { EmptyView() From 15b54141e510a714c592c47c857031d6ab0e7471 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 21:42:56 -0700 Subject: [PATCH 32/70] title attribute no longer in use --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 4 ++-- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 8 ++++---- Bedtime/Shared/BedtimeActivityAttributes.swift | 2 -- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 2e956b0..1b556a3 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -85,7 +85,7 @@ final class LiveActivityManager: ObservableObject { do { let activity = try Activity.request( - attributes: BedtimeActivityAttributes(title: "Bedger"), + attributes: BedtimeActivityAttributes(), content: content, pushType: nil ) @@ -159,7 +159,7 @@ final class LiveActivityManager: ObservableObject { do { let activity = try Activity.request( - attributes: BedtimeActivityAttributes(title: "Bedger"), + attributes: BedtimeActivityAttributes(), content: ActivityContent(state: state, staleDate: schedule.bedtime), pushType: nil, style: .standard, diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index a263006..945b3f2 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -268,28 +268,28 @@ extension BedtimeActivityAttributes.ContentState { } } -#Preview("Lock Screen", as: .content, using: BedtimeActivityAttributes(title: "Bedger")) { +#Preview("Lock Screen", as: .content, using: BedtimeActivityAttributes()) { BedtimeLiveActivity() } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample } -#Preview("Island Expanded", as: .dynamicIsland(.expanded), using: BedtimeActivityAttributes(title: "Bedger")) { +#Preview("Island Expanded", as: .dynamicIsland(.expanded), using: BedtimeActivityAttributes()) { BedtimeLiveActivity() } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample } -#Preview("Island Compact", as: .dynamicIsland(.compact), using: BedtimeActivityAttributes(title: "Bedger")) { +#Preview("Island Compact", as: .dynamicIsland(.compact), using: BedtimeActivityAttributes()) { BedtimeLiveActivity() } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample } -#Preview("Island Minimal", as: .dynamicIsland(.minimal), using: BedtimeActivityAttributes(title: "Bedger")) { +#Preview("Island Minimal", as: .dynamicIsland(.minimal), using: BedtimeActivityAttributes()) { BedtimeLiveActivity() } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 09ed306..9b89bea 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -16,6 +16,4 @@ nonisolated struct BedtimeActivityAttributes: ActivityAttributes { /// own, which `staleDate` can only do for a date still ahead of it. let isSleeping: Bool } - - let title: String } From b4bee727ee5eeb20def6a0a51d7bf5854501c8f1 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 21:43:32 -0700 Subject: [PATCH 33/70] remove redundant expanded island title --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 945b3f2..68c1cb6 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -21,15 +21,7 @@ struct BedtimeLiveActivity: Widget { .activitySystemActionForegroundColor(.primary) } dynamicIsland: { context in DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Label( - context.attributes.title, - systemImage: BedtimePhase(context).symbol - ) - .font(.headline) - } - - DynamicIslandExpandedRegion(.bottom) { + DynamicIslandExpandedRegion(.center) { BedtimeSummaryView( state: context.state, phase: BedtimePhase(context) From 71dd00017dabdac3aa27dc681cac26549c803e4c Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 21:44:37 -0700 Subject: [PATCH 34/70] consistent layout --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 68c1cb6..348ff1a 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -206,9 +206,13 @@ private struct BedtimeSummaryView: View { Spacer(minLength: 8) - Text("\(targetText) target") - .font(.caption) - .foregroundStyle(.secondary) + VStack { + Text("Target") + .font(.caption) + .foregroundStyle(.secondary) + Text(targetText) + .font(.headline) + } Spacer(minLength: 8) From ea6e0adccece97853ca0a9b803c2dea5e0da6e87 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 21:59:41 -0700 Subject: [PATCH 35/70] minor --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 1b556a3..834c8b0 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -140,10 +140,10 @@ final class LiveActivityManager: ObservableObject { schedule: (bedtime: Date, wakeTime: Date), startingAt start: Date ) async { - for activity in Activity.activities { - guard activity.activityState == .pending else { continue } - // Tonight is already queued up. - guard activity.content.state.bedtime != schedule.bedtime else { return } + // remove any existing activities scheduled to start at different times. + // It is not possible to update the start time of a scheduled activity + for activity in Activity.activities where activity.activityState == .pending { + if activity.content.state.bedtime == schedule.bedtime { return } await activity.end(nil, dismissalPolicy: .immediate) } From b6f29cca1d3f4ab4c8c72fac00d92166c5b23fea Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 22:24:30 -0700 Subject: [PATCH 36/70] custom time formatter --- .../BedtimeLiveActivity.swift | 114 +++++++++++------- 1 file changed, 69 insertions(+), 45 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 348ff1a..135d5e1 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -36,16 +36,11 @@ struct BedtimeLiveActivity: Widget { .foregroundStyle(.indigo) .lineLimit(1) } compactTrailing: { - CountdownText( - state: context.state, - phase: BedtimePhase(context), - style: .compact - ) - .lineLimit(1) - .minimumScaleFactor(0.5) - // Without a cap the compact region grows to fit its text and - // stretches the whole pill. - .frame(maxWidth: 56) + CountdownText(context) + .lineLimit(1) + .minimumScaleFactor(0.5) + // Hugging doesn't work, needs a cap or it fills all available space. + .frame(maxWidth: 56) } minimal: { // A second app's activity forces both into this presentation, // which is too small for "in 2 hours". Every compact numeric form @@ -87,11 +82,10 @@ private enum BedtimePhase { } } - /// `DateReference` supplies its own "in …", so this is only the subject. var countdownLabel: String { switch self { - case .windDown: "Bedtime" - case .sleeping: "Wake" + case .windDown: "Bedtime in" + case .sleeping: "Wake in" } } @@ -123,46 +117,76 @@ private enum BedtimePhase { } } -/// 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. +/// The remaining time has to come from a `DiscreteFormatStyle` so the system keeps +/// re-rendering it while the app is suspended. The system's `.offset` truncates +/// ("1 hour" at 1:59 remaining) while `.reference` rounds, so the two presentations +/// used to disagree by up to an hour; both now share one rounding style and prepositions +/// are handled externally. private struct CountdownText: View { - enum Style { - /// "in 2 hours", which reads as a sentence next to its subject. - case phrase - /// "2 hours", dropping the preposition where width is scarce. - case compact - } let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase - var style: Style = .phrase var body: some View { - switch style { - case .phrase: - Text( - .currentDate, - format: .reference( - to: phase.target(in: state), - allowedFields: [.hour, .minute], - thresholdField: .hour - ) - ) - case .compact: - Text( - .currentDate, - format: .offset( - to: phase.target(in: state), - allowedFields: [.hour, .minute], - maxFieldCount: 1, - sign: .never - ) + Text( + .currentDate, + format: RoundedCountdownStyle( + target: phase.target(in: state) ) + ) + } +} + +extension CountdownText { + init(_ context: ActivityViewContext) { + let phase = BedtimePhase(context) + self.init(state: context.state, phase: phase) + } +} + +/// A countdown to `target` rounded to its largest field, so "2 hours" is shown from +/// 1:30 remaining rather than only above 2:00. `Duration.UnitsFormatStyle` does the +/// rounding and knows where its own output changes; this only maps dates into the +/// duration domain and back so the system can schedule re-renders at those boundaries. +private struct RoundedCountdownStyle: DiscreteFormatStyle, Codable, Hashable { + var target: Date + + /// A single hour-or-minute field keeps the phrasing at "8 hours" / "20 minutes". + private var units: Duration.UnitsFormatStyle { + .units(allowed: [.hours, .minutes], width: .wide, maximumUnitCount: 1) + } + + /// Clamped so a lapsed target holds at zero instead of counting back up. + private func remaining(at date: Date) -> Duration { + .seconds(max(0, target.timeIntervalSince(date))) + } + + func format(_ value: Date) -> String { + units.format(remaining(at: value)) + } + + // Remaining time shrinks as the input date grows, so date-domain boundaries + // come from duration-domain boundaries in the opposite direction. + func discreteInput(before input: Date) -> Date? { + guard let boundary = units.discreteInput(after: remaining(at: input)) else { + return nil } + return target.addingTimeInterval(-boundary.timeInterval) + } + + func discreteInput(after input: Date) -> Date? { + // Once the target passes, the clamped output is frozen at zero. + guard input < target, + let boundary = units.discreteInput(before: remaining(at: input)) else { + return nil + } + return target.addingTimeInterval(-boundary.timeInterval) + } +} + +extension Duration { + fileprivate var timeInterval: TimeInterval { + Double(components.seconds) + Double(components.attoseconds) * 1e-18 } } From c2bc3dcbfdeaa5bcdf26ac4b3f2f523dcbfcafee Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 22:27:13 -0700 Subject: [PATCH 37/70] revert the convenience initializer, it was overkill --- .../BedtimeLiveActivity.swift | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 135d5e1..0083614 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -36,11 +36,14 @@ struct BedtimeLiveActivity: Widget { .foregroundStyle(.indigo) .lineLimit(1) } compactTrailing: { - CountdownText(context) - .lineLimit(1) - .minimumScaleFactor(0.5) - // Hugging doesn't work, needs a cap or it fills all available space. - .frame(maxWidth: 56) + CountdownText( + state: context.state, + phase: BedtimePhase(context), + ) + .lineLimit(1) + .minimumScaleFactor(0.5) + // Hugging doesn't work, needs a cap or it fills all available space. + .frame(maxWidth: 56) } minimal: { // A second app's activity forces both into this presentation, // which is too small for "in 2 hours". Every compact numeric form @@ -137,13 +140,6 @@ private struct CountdownText: View { } } -extension CountdownText { - init(_ context: ActivityViewContext) { - let phase = BedtimePhase(context) - self.init(state: context.state, phase: phase) - } -} - /// A countdown to `target` rounded to its largest field, so "2 hours" is shown from /// 1:30 remaining rather than only above 2:00. `Duration.UnitsFormatStyle` does the /// rounding and knows where its own output changes; this only maps dates into the From cb92dddbf01301d1c55c7da7bbffe46311b6d783 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 22:34:10 -0700 Subject: [PATCH 38/70] minimal also as text? --- .../BedtimeLiveActivity.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 0083614..4e022ef 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -46,20 +46,16 @@ struct BedtimeLiveActivity: Widget { .frame(maxWidth: 56) } minimal: { // A second app's activity forces both into this presentation, - // which is too small for "in 2 hours". Every compact numeric form - // the system keeps updating carries ticking seconds, so this - // shows the same progress as a ring instead. - let phase = BedtimePhase(context) - ProgressView( - timerInterval: phase.progressRange(in: context.state), - countsDown: phase == .windDown - ) { - EmptyView() - } currentValueLabel: { - Image(systemName: phase.symbol) - } - .progressViewStyle(.circular) - .tint(.indigo) + // which is too small for "in 2 hours". At narrow width the shared + // style collapses to a single "10h" / "45m" field that fits. + CountdownText( + state: context.state, + phase: BedtimePhase(context), + width: .narrow + ) + .foregroundStyle(.indigo) + .lineLimit(1) + .minimumScaleFactor(0.5) } .keylineTint(.indigo) } @@ -129,12 +125,14 @@ private struct CountdownText: View { let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase + var width: Duration.UnitsFormatStyle.UnitWidth = .wide var body: some View { Text( .currentDate, format: RoundedCountdownStyle( - target: phase.target(in: state) + target: phase.target(in: state), + width: width ) ) } @@ -146,10 +144,12 @@ private struct CountdownText: View { /// duration domain and back so the system can schedule re-renders at those boundaries. private struct RoundedCountdownStyle: DiscreteFormatStyle, Codable, Hashable { var target: Date + /// `.wide` reads as "8 hours"; `.narrow` compresses to "8h" for the minimal slot. + var width: Duration.UnitsFormatStyle.UnitWidth = .wide - /// A single hour-or-minute field keeps the phrasing at "8 hours" / "20 minutes". + /// A single hour-or-minute field keeps it to one number and one unit. private var units: Duration.UnitsFormatStyle { - .units(allowed: [.hours, .minutes], width: .wide, maximumUnitCount: 1) + .units(allowed: [.hours, .minutes], width: width, maximumUnitCount: 1) } /// Clamped so a lapsed target holds at zero instead of counting back up. From 2e3ace7df304626422e20f745122192b0aa27ce1 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 22:43:13 -0700 Subject: [PATCH 39/70] flip countdown bar --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 4e022ef..3f0e3af 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -220,6 +220,12 @@ private struct BedtimeSummaryView: View { EmptyView() } .tint(.indigo) + // The bar always anchors its fill to the leading edge, so when it + // counts down the emptiness grows from the right — backwards next to + // the schedule row below. Flipping the layout direction makes it + // drain from the left; while sleeping the bar counts up instead and + // its left-to-right fill toward Wake already reads correctly. + .environment(\.layoutDirection, phase == .windDown ? .rightToLeft : .leftToRight) HStack(alignment: .firstTextBaseline) { scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) From b5e1e517b14509175d1eef6bc971f2103b5fb5e9 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 12 Aug 2026 22:45:57 -0700 Subject: [PATCH 40/70] handle r-to-l layouts --- .../BedtimeLiveActivity.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 3f0e3af..ca04305 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -190,6 +190,12 @@ private struct BedtimeSummaryView: View { let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase + @Environment(\.layoutDirection) private var layoutDirection + + private var flippedLayoutDirection: LayoutDirection { + layoutDirection == .leftToRight ? .rightToLeft : .leftToRight + } + 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. @@ -221,11 +227,12 @@ private struct BedtimeSummaryView: View { } .tint(.indigo) // The bar always anchors its fill to the leading edge, so when it - // counts down the emptiness grows from the right — backwards next to - // the schedule row below. Flipping the layout direction makes it - // drain from the left; while sleeping the bar counts up instead and - // its left-to-right fill toward Wake already reads correctly. - .environment(\.layoutDirection, phase == .windDown ? .rightToLeft : .leftToRight) + // counts down the emptiness grows from the trailing side — backwards + // next to the schedule row below. 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. + .environment(\.layoutDirection, phase == .windDown ? flippedLayoutDirection : layoutDirection) HStack(alignment: .firstTextBaseline) { scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) From 1573ffff893d04dff13498ac60f0e9d9aecd4d96 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 00:02:55 -0700 Subject: [PATCH 41/70] revert custom formatter --- .../BedtimeLiveActivity.swift | 124 ++++++++---------- 1 file changed, 52 insertions(+), 72 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index ca04305..fb02985 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -39,23 +39,29 @@ struct BedtimeLiveActivity: Widget { CountdownText( state: context.state, phase: BedtimePhase(context), + style: .compact ) .lineLimit(1) .minimumScaleFactor(0.5) - // Hugging doesn't work, needs a cap or it fills all available space. + // Without a cap the compact region grows to fit its text and + // stretches the whole pill. .frame(maxWidth: 56) } minimal: { // A second app's activity forces both into this presentation, - // which is too small for "in 2 hours". At narrow width the shared - // style collapses to a single "10h" / "45m" field that fits. - CountdownText( - state: context.state, - phase: BedtimePhase(context), - width: .narrow - ) - .foregroundStyle(.indigo) - .lineLimit(1) - .minimumScaleFactor(0.5) + // which is too small for "in 2 hours". Every compact numeric form + // the system keeps updating carries ticking seconds, so this + // shows the same progress as a ring instead. + let phase = BedtimePhase(context) + ProgressView( + timerInterval: phase.progressRange(in: context.state), + countsDown: phase == .windDown + ) { + EmptyView() + } currentValueLabel: { + Image(systemName: phase.symbol) + } + .progressViewStyle(.circular) + .tint(.indigo) } .keylineTint(.indigo) } @@ -81,10 +87,11 @@ private enum BedtimePhase { } } + /// `DateReference` supplies its own "in …", so this is only the subject. var countdownLabel: String { switch self { - case .windDown: "Bedtime in" - case .sleeping: "Wake in" + case .windDown: "Bedtime" + case .sleeping: "Wake" } } @@ -116,73 +123,46 @@ private enum BedtimePhase { } } -/// The remaining time has to come from a `DiscreteFormatStyle` so the system keeps -/// re-rendering it while the app is suspended. The system's `.offset` truncates -/// ("1 hour" at 1:59 remaining) while `.reference` rounds, so the two presentations -/// used to disagree by up to an hour; both now share one rounding style and prepositions -/// are handled externally. +/// 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 { + enum Style { + /// "in 2 hours", which reads as a sentence next to its subject. + case phrase + /// "2 hours", dropping the preposition where width is scarce. + case compact + } let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase - var width: Duration.UnitsFormatStyle.UnitWidth = .wide + var style: Style = .phrase var body: some View { - Text( - .currentDate, - format: RoundedCountdownStyle( - target: phase.target(in: state), - width: width + switch style { + case .phrase: + Text( + .currentDate, + format: .reference( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + thresholdField: .hour + ) + ) + case .compact: + Text( + .currentDate, + format: .offset( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + maxFieldCount: 1, + sign: .never + ) ) - ) - } -} - -/// A countdown to `target` rounded to its largest field, so "2 hours" is shown from -/// 1:30 remaining rather than only above 2:00. `Duration.UnitsFormatStyle` does the -/// rounding and knows where its own output changes; this only maps dates into the -/// duration domain and back so the system can schedule re-renders at those boundaries. -private struct RoundedCountdownStyle: DiscreteFormatStyle, Codable, Hashable { - var target: Date - /// `.wide` reads as "8 hours"; `.narrow` compresses to "8h" for the minimal slot. - var width: Duration.UnitsFormatStyle.UnitWidth = .wide - - /// A single hour-or-minute field keeps it to one number and one unit. - private var units: Duration.UnitsFormatStyle { - .units(allowed: [.hours, .minutes], width: width, maximumUnitCount: 1) - } - - /// Clamped so a lapsed target holds at zero instead of counting back up. - private func remaining(at date: Date) -> Duration { - .seconds(max(0, target.timeIntervalSince(date))) - } - - func format(_ value: Date) -> String { - units.format(remaining(at: value)) - } - - // Remaining time shrinks as the input date grows, so date-domain boundaries - // come from duration-domain boundaries in the opposite direction. - func discreteInput(before input: Date) -> Date? { - guard let boundary = units.discreteInput(after: remaining(at: input)) else { - return nil - } - return target.addingTimeInterval(-boundary.timeInterval) - } - - func discreteInput(after input: Date) -> Date? { - // Once the target passes, the clamped output is frozen at zero. - guard input < target, - let boundary = units.discreteInput(before: remaining(at: input)) else { - return nil } - return target.addingTimeInterval(-boundary.timeInterval) - } -} - -extension Duration { - fileprivate var timeInterval: TimeInterval { - Double(components.seconds) + Double(components.attoseconds) * 1e-18 } } From 6e03790de09828e99f97a20bbbf2502bdcf364b1 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 00:03:07 -0700 Subject: [PATCH 42/70] all reference --- .../BedtimeLiveActivity.swift | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index fb02985..183ac41 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -39,7 +39,6 @@ struct BedtimeLiveActivity: Widget { CountdownText( state: context.state, phase: BedtimePhase(context), - style: .compact ) .lineLimit(1) .minimumScaleFactor(0.5) @@ -130,39 +129,19 @@ private enum BedtimePhase { /// "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 { - enum Style { - /// "in 2 hours", which reads as a sentence next to its subject. - case phrase - /// "2 hours", dropping the preposition where width is scarce. - case compact - } let state: BedtimeActivityAttributes.ContentState let phase: BedtimePhase - var style: Style = .phrase var body: some View { - switch style { - case .phrase: - Text( - .currentDate, - format: .reference( - to: phase.target(in: state), - allowedFields: [.hour, .minute], - thresholdField: .hour - ) - ) - case .compact: - Text( - .currentDate, - format: .offset( - to: phase.target(in: state), - allowedFields: [.hour, .minute], - maxFieldCount: 1, - sign: .never - ) + Text( + .currentDate, + format: .reference( + to: phase.target(in: state), + allowedFields: [.hour, .minute], + thresholdField: .hour ) - } + ) } } From 5f4039d65634226038989184fb79b355dfe74819 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 16:00:02 +0000 Subject: [PATCH 43/70] Add small padding to minimal Dynamic Island icon Co-authored-by: Greg --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 183ac41..339699e 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -58,6 +58,7 @@ struct BedtimeLiveActivity: Widget { EmptyView() } currentValueLabel: { Image(systemName: phase.symbol) + .padding(2) } .progressViewStyle(.circular) .tint(.indigo) From 6a8c7a9807a0dd4a49891a028d023c790c16ead9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 16:56:56 +0000 Subject: [PATCH 44/70] Minor style cleanup in BedtimeLiveActivity.swift Co-authored-by: Greg --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 339699e..0a8cbfe 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -38,7 +38,7 @@ struct BedtimeLiveActivity: Widget { } compactTrailing: { CountdownText( state: context.state, - phase: BedtimePhase(context), + phase: BedtimePhase(context) ) .lineLimit(1) .minimumScaleFactor(0.5) @@ -130,7 +130,6 @@ private enum BedtimePhase { /// "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 @@ -229,6 +228,7 @@ private struct BedtimeSummaryView: View { } } } + extension BedtimeActivityAttributes.ContentState { /// Wind-down: bedtime is still ahead. fileprivate static var windDownSample: Self { From 820efb5e26314589165657ba95a8b2fc9ca9201f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 17:10:19 +0000 Subject: [PATCH 45/70] Add linear progress bar under compact trailing countdown Co-authored-by: Greg --- .../BedtimeLiveActivity.swift | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 0a8cbfe..4e5a910 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -36,12 +36,15 @@ struct BedtimeLiveActivity: Widget { .foregroundStyle(.indigo) .lineLimit(1) } compactTrailing: { - CountdownText( - state: context.state, - phase: BedtimePhase(context) - ) - .lineLimit(1) - .minimumScaleFactor(0.5) + let phase = BedtimePhase(context) + VStack(spacing: 2) { + CountdownText(state: context.state, phase: phase) + .lineLimit(1) + .minimumScaleFactor(0.5) + + PhaseProgressBar(state: context.state, phase: phase) + .frame(height: 3) + } // Without a cap the compact region grows to fit its text and // stretches the whole pill. .frame(maxWidth: 56) @@ -145,7 +148,13 @@ private struct CountdownText: View { } } -private struct BedtimeSummaryView: View { +/// 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 @@ -155,6 +164,21 @@ private struct BedtimeSummaryView: View { layoutDirection == .leftToRight ? .rightToLeft : .leftToRight } + var body: some View { + ProgressView(timerInterval: phase.progressRange(in: state), 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. @@ -179,19 +203,7 @@ private struct BedtimeSummaryView: View { .lineLimit(1) .minimumScaleFactor(0.7) - ProgressView(timerInterval: phase.progressRange(in: state), countsDown: phase == .windDown) { - EmptyView() - } currentValueLabel: { - EmptyView() - } - .tint(.indigo) - // 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. 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. - .environment(\.layoutDirection, phase == .windDown ? flippedLayoutDirection : layoutDirection) + PhaseProgressBar(state: state, phase: phase) HStack(alignment: .firstTextBaseline) { scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) From 7f3e99d099e8504b8ff4d8669a1c69a68fa3dee8 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:24:31 -0700 Subject: [PATCH 46/70] min 30 minute live progress duration --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 834c8b0..5436823 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -33,6 +33,11 @@ final class LiveActivityManager: ObservableObject { activeActivityID = Activity.activities.first?.id } + func newStartTime(bedtime: Date, now: Date) -> Date? { + let defaultTime = bedtime.addingTimeInterval(.minutes(-Constants.liveActivityLeadTime)) + return min(now, defaultTime) + } + func startOrUpdate( with recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle @@ -54,10 +59,9 @@ final class LiveActivityManager: ObservableObject { let now = Date() let schedule = upcomingSchedule(for: recommendation, now: now) let isSleeping = now >= schedule.bedtime + let startTime = Self.activity(withID: activeActivityID)?.content.state.activityStart ?? newStartTime(bedtime: bedtime, now: now) let state = BedtimeActivityAttributes.ContentState( - // Updating an existing activity keeps its original start so the - // wind-down bar doesn't jump back to empty. - activityStart: Self.activity(withID: activeActivityID)?.content.state.activityStart ?? now, + activityStart: startTime, bedtime: schedule.bedtime, wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, From cdc2ed735e1e1d88a95421e548ec15cecb0db138 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:25:28 -0700 Subject: [PATCH 47/70] move range/target math from Phase enum to state struct --- .../BedtimeLiveActivity.swift | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 4e5a910..df649f9 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -45,17 +45,13 @@ struct BedtimeLiveActivity: Widget { PhaseProgressBar(state: context.state, phase: phase) .frame(height: 3) } - // Without a cap the compact region grows to fit its text and - // stretches the whole pill. + // Text doesn't hug so without a cap the region + // grows to fill the whole side of the status bar. .frame(maxWidth: 56) } minimal: { - // A second app's activity forces both into this presentation, - // which is too small for "in 2 hours". Every compact numeric form - // the system keeps updating carries ticking seconds, so this - // shows the same progress as a ring instead. let phase = BedtimePhase(context) ProgressView( - timerInterval: phase.progressRange(in: context.state), + timerInterval: context.state.progressRange(for: phase), countsDown: phase == .windDown ) { EmptyView() @@ -106,22 +102,27 @@ private enum BedtimePhase { case .sleeping: "Wake" } } +} - func target(in state: BedtimeActivityAttributes.ContentState) -> Date { - switch self { - case .windDown: state.bedtime - case .sleeping: state.wakeTime +private extension BedtimeActivityAttributes.ContentState { + + func start(for phase: BedtimePhase) -> Date { + switch phase { + case .windDown: activityStart + case .sleeping: bedtime } } - func progressRange( - in state: BedtimeActivityAttributes.ContentState - ) -> ClosedRange { - let start = switch self { - case .windDown: state.activityStart - case .sleeping: state.bedtime + func target(for phase: BedtimePhase) -> Date { + switch phase { + case .windDown: bedtime + case .sleeping: wakeTime } - let end = target(in: state) + } + + func progressRange(for phase: BedtimePhase) -> ClosedRange { + let start = start(for: phase) + let end = target(for: phase) return min(start, end)...max(start, end) } } @@ -140,7 +141,7 @@ private struct CountdownText: View { Text( .currentDate, format: .reference( - to: phase.target(in: state), + to: state.target(for: phase), allowedFields: [.hour, .minute], thresholdField: .hour ) @@ -165,7 +166,10 @@ private struct PhaseProgressBar: View { } var body: some View { - ProgressView(timerInterval: phase.progressRange(in: state), countsDown: phase == .windDown) { + ProgressView( + timerInterval: state.progressRange(for: phase), + countsDown: phase == .windDown + ) { EmptyView() } currentValueLabel: { EmptyView() From a4148c6276f87b62e6a27ae4e660d007435d0645 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:37:54 -0700 Subject: [PATCH 48/70] fixup --- .../Bedtime/Models/LiveActivityManager.swift | 2 +- Bedtime/Shared/TimeInterval.swift | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Bedtime/Shared/TimeInterval.swift diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 5436823..8105c77 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -34,7 +34,7 @@ final class LiveActivityManager: ObservableObject { } func newStartTime(bedtime: Date, now: Date) -> Date? { - let defaultTime = bedtime.addingTimeInterval(.minutes(-Constants.liveActivityLeadTime)) + let defaultTime = bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) return min(now, defaultTime) } diff --git a/Bedtime/Shared/TimeInterval.swift b/Bedtime/Shared/TimeInterval.swift new file mode 100644 index 0000000..7c86eb8 --- /dev/null +++ b/Bedtime/Shared/TimeInterval.swift @@ -0,0 +1,32 @@ +// +// TimeInterval.swift +// Bedtime +// +// Created by Greg Bernstein on 8/13/26. +// + +import Foundation + +nonisolated 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) + } + +} From d196d9252015a76b6fc19343b0bdec76b1f683d1 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:38:21 -0700 Subject: [PATCH 49/70] use throughout --- Bedtime/Bedtime/Constants.swift | 2 +- Bedtime/Bedtime/Models/BedtimePlanStore.swift | 2 +- Bedtime/Bedtime/Models/SleepData.swift | 2 +- .../Views/Components/SleepSourceComparisonView.swift | 8 ++++---- Bedtime/Bedtime/Views/LastNightCard.swift | 2 +- Bedtime/Bedtime/Views/RecentSleepSessionsCard.swift | 2 +- .../BedtimeLiveActivity/BedtimeLiveActivity.swift | 12 ++++++------ 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 9eb6653..3fa94d4 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -22,7 +22,7 @@ nonisolated enum Constants { 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 = 30 * 60 + static let liveActivityLeadTime: TimeInterval = .minutes(30) /// Goal choices as step offsets from the lowest goal, so pickers can tag /// options with values that compare exactly. diff --git a/Bedtime/Bedtime/Models/BedtimePlanStore.swift b/Bedtime/Bedtime/Models/BedtimePlanStore.swift index 37e998f..542fef9 100644 --- a/Bedtime/Bedtime/Models/BedtimePlanStore.swift +++ b/Bedtime/Bedtime/Models/BedtimePlanStore.swift @@ -21,7 +21,7 @@ struct BedtimePlan: Codable { enum BedtimePlanStore { /// Plans older than this are treated as missing; the schedule they were built /// from has probably moved on. - static let maximumAge: TimeInterval = 7 * 24 * 60 * 60 + static let maximumAge: TimeInterval = .days(7) private static let key = "tonightsBedtimePlan" diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index e1688ee..5fcc232 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) } } 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/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index df649f9..16ad235 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -250,9 +250,9 @@ extension BedtimeActivityAttributes.ContentState { fileprivate static var windDownSample: Self { let now = Date() return Self( - activityStart: now.addingTimeInterval(-30 * 60), - bedtime: now.addingTimeInterval(2 * 60 * 60), - wakeTime: now.addingTimeInterval(10 * 60 * 60), + activityStart: now.addingTimeInterval(.minutes(-30)), + bedtime: now.addingTimeInterval(.hours(2)), + wakeTime: now.addingTimeInterval(.hours(10)), targetSleepHours: 8, durationStyle: .hoursAndMinutes, isSleeping: false @@ -263,9 +263,9 @@ extension BedtimeActivityAttributes.ContentState { fileprivate static var sleepingSample: Self { let now = Date() return Self( - activityStart: now.addingTimeInterval(-3 * 60 * 60), - bedtime: now.addingTimeInterval(-60 * 60), - wakeTime: now.addingTimeInterval(7 * 60 * 60), + activityStart: now.addingTimeInterval(.hours(-3)), + bedtime: now.addingTimeInterval(.hours(-1)), + wakeTime: now.addingTimeInterval(.hours(7)), targetSleepHours: 8, durationStyle: .hoursAndMinutes, isSleeping: true From 88d331766ac665053ece8fe2ff2daead6829a63e Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:38:34 -0700 Subject: [PATCH 50/70] rename and use helper --- Bedtime/Bedtime/Models/ViewModel.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 40708a2..28750de 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,9 +138,9 @@ 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) - + let sleepNeededMinutes = Int((totalHoursNeeded * 60).rounded()) + let recommendedBedtime = calendar.date(byAdding: .minute, value: -sleepNeededMinutes, to: wakeTime) ?? wakeTime.addingTimeInterval(.hours(-totalHoursNeeded)) + return BedtimeRecommendation( recommendedBedtime: recommendedBedtime, wakeTime: wakeTime, From d3539d20a85d9a1bd13ebcedcd60e13dc0d1cb10 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:42:49 -0700 Subject: [PATCH 51/70] fixups --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 7 ++++--- Bedtime/Bedtime/Models/ViewModel.swift | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 8105c77..8329a01 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -33,8 +33,9 @@ final class LiveActivityManager: ObservableObject { activeActivityID = Activity.activities.first?.id } - func newStartTime(bedtime: Date, now: Date) -> Date? { - let defaultTime = bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) + func newStartTime(bedtime: Date, now: Date) -> Date { + let leadTimeInt: Int = Int(Constants.liveActivityLeadTime.rounded()) + let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: .minute, value: -leadTimeInt, to: bedtime) ?? bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) return min(now, defaultTime) } @@ -59,7 +60,7 @@ final class LiveActivityManager: ObservableObject { let now = Date() let schedule = upcomingSchedule(for: recommendation, now: now) let isSleeping = now >= schedule.bedtime - let startTime = Self.activity(withID: activeActivityID)?.content.state.activityStart ?? newStartTime(bedtime: bedtime, now: now) + let startTime = Self.activity(withID: activeActivityID)?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) let state = BedtimeActivityAttributes.ContentState( activityStart: startTime, bedtime: schedule.bedtime, diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index 28750de..bfa044b 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -144,7 +144,7 @@ class ViewModel { return BedtimeRecommendation( recommendedBedtime: recommendedBedtime, wakeTime: wakeTime, - targetSleepDuration: totalSleepNeeded, + targetSleepDuration: totalHoursNeeded, reason: reason ) } From 6ebca9910ec7728570d079627f71fb8cff709aac Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 21:46:30 -0700 Subject: [PATCH 52/70] narrower --- Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index 16ad235..f24f494 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -43,7 +43,7 @@ struct BedtimeLiveActivity: Widget { .minimumScaleFactor(0.5) PhaseProgressBar(state: context.state, phase: phase) - .frame(height: 3) + .frame(height: 2) } // Text doesn't hug so without a cap the region // grows to fill the whole side of the status bar. From 04716bb204c8e921edf32b206f62f331cfbd35c2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 04:48:34 +0000 Subject: [PATCH 53/70] Fix newStartTime treating lead time seconds as minutes Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 8329a01..7211f4a 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -34,8 +34,7 @@ final class LiveActivityManager: ObservableObject { } func newStartTime(bedtime: Date, now: Date) -> Date { - let leadTimeInt: Int = Int(Constants.liveActivityLeadTime.rounded()) - let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: .minute, value: -leadTimeInt, to: bedtime) ?? bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) + let defaultTime = bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) return min(now, defaultTime) } From bff13212f2638a28c6b6de91cb08d528894d6100 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 04:52:58 +0000 Subject: [PATCH 54/70] Keep Calendar-based newStartTime, fix units to seconds Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 7211f4a..0d673e2 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -34,7 +34,8 @@ final class LiveActivityManager: ObservableObject { } func newStartTime(bedtime: Date, now: Date) -> Date { - let defaultTime = bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) + let leadTimeInt: Int = Int(Constants.liveActivityLeadTime.rounded()) + let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: .second, value: -leadTimeInt, to: bedtime) ?? bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) return min(now, defaultTime) } From 5ba5d68210b52a89de375af756b933255f37a721 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 04:55:52 +0000 Subject: [PATCH 55/70] Add Calendar+TimeInterval helper for DST-safe interval math Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 5 ++--- Bedtime/Bedtime/Models/ViewModel.swift | 3 +-- Bedtime/Shared/TimeInterval.swift | 13 +++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 0d673e2..2d6854f 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -34,8 +34,7 @@ final class LiveActivityManager: ObservableObject { } func newStartTime(bedtime: Date, now: Date) -> Date { - let leadTimeInt: Int = Int(Constants.liveActivityLeadTime.rounded()) - let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: .second, value: -leadTimeInt, to: bedtime) ?? bedtime.addingTimeInterval(-Constants.liveActivityLeadTime) + let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: -Constants.liveActivityLeadTime, to: bedtime) return min(now, defaultTime) } @@ -114,7 +113,7 @@ final class LiveActivityManager: ObservableObject { guard isSupported, areActivitiesEnabled else { return } let schedule = upcomingSchedule(for: recommendation, now: now) - let windowOpens = schedule.bedtime.addingTimeInterval(-leadTime) + let windowOpens = Calendar.autoupdatingCurrent.date(byAdding: -leadTime, to: schedule.bedtime) // Once bedtime passes, the next match is tomorrow's, so this only opens // the window ahead of tonight's bedtime. An activity already running diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index bfa044b..ea7930d 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -138,8 +138,7 @@ class ViewModel { reason = "You're ahead of the game! Aim for at least \(goal) tonight." } - let sleepNeededMinutes = Int((totalHoursNeeded * 60).rounded()) - let recommendedBedtime = calendar.date(byAdding: .minute, value: -sleepNeededMinutes, to: wakeTime) ?? wakeTime.addingTimeInterval(.hours(-totalHoursNeeded)) + let recommendedBedtime = calendar.date(byAdding: -TimeInterval.hours(totalHoursNeeded), to: wakeTime) return BedtimeRecommendation( recommendedBedtime: recommendedBedtime, diff --git a/Bedtime/Shared/TimeInterval.swift b/Bedtime/Shared/TimeInterval.swift index 7c86eb8..da8b533 100644 --- a/Bedtime/Shared/TimeInterval.swift +++ b/Bedtime/Shared/TimeInterval.swift @@ -30,3 +30,16 @@ nonisolated extension TimeInterval { } } + +nonisolated extension Calendar { + + /// Adds a time interval by calendar arithmetic rather than raw elapsed + /// seconds, so a wind-down window or lead time still lands on the same + /// wall-clock offset across a DST transition. Falls back to + /// `addingTimeInterval` on the rare date this can't be resolved. + func date(byAdding interval: TimeInterval, to date: Date) -> Date { + let seconds = Int(interval.rounded()) + return self.date(byAdding: .second, value: seconds, to: date) ?? date.addingTimeInterval(interval) + } + +} From 3e344b5ffe26848e226df69589eddf5f150a0d76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:12:16 +0000 Subject: [PATCH 56/70] Revert lead-time/sleep-duration math to elapsed-time subtraction Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 9 +++++++-- Bedtime/Bedtime/Models/ViewModel.swift | 5 ++++- Bedtime/Shared/TimeInterval.swift | 13 ------------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 2d6854f..4a6436f 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -34,7 +34,10 @@ final class LiveActivityManager: ObservableObject { } func newStartTime(bedtime: Date, now: Date) -> Date { - let defaultTime = Calendar.autoupdatingCurrent.date(byAdding: -Constants.liveActivityLeadTime, to: bedtime) + // 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) } @@ -113,7 +116,9 @@ final class LiveActivityManager: ObservableObject { guard isSupported, areActivitiesEnabled else { return } let schedule = upcomingSchedule(for: recommendation, now: now) - let windowOpens = Calendar.autoupdatingCurrent.date(byAdding: -leadTime, to: schedule.bedtime) + // 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 diff --git a/Bedtime/Bedtime/Models/ViewModel.swift b/Bedtime/Bedtime/Models/ViewModel.swift index ea7930d..1996904 100644 --- a/Bedtime/Bedtime/Models/ViewModel.swift +++ b/Bedtime/Bedtime/Models/ViewModel.swift @@ -138,7 +138,10 @@ class ViewModel { reason = "You're ahead of the game! Aim for at least \(goal) tonight." } - let recommendedBedtime = calendar.date(byAdding: -TimeInterval.hours(totalHoursNeeded), to: wakeTime) + // 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, diff --git a/Bedtime/Shared/TimeInterval.swift b/Bedtime/Shared/TimeInterval.swift index da8b533..7c86eb8 100644 --- a/Bedtime/Shared/TimeInterval.swift +++ b/Bedtime/Shared/TimeInterval.swift @@ -30,16 +30,3 @@ nonisolated extension TimeInterval { } } - -nonisolated extension Calendar { - - /// Adds a time interval by calendar arithmetic rather than raw elapsed - /// seconds, so a wind-down window or lead time still lands on the same - /// wall-clock offset across a DST transition. Falls back to - /// `addingTimeInterval` on the rare date this can't be resolved. - func date(byAdding interval: TimeInterval, to date: Date) -> Date { - let seconds = Int(interval.rounded()) - return self.date(byAdding: .second, value: seconds, to: date) ?? date.addingTimeInterval(interval) - } - -} From 481fc66194ebcbb8de3dce5144c63861e25e2d65 Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 13 Aug 2026 22:51:31 -0700 Subject: [PATCH 57/70] remove separate extension scheme --- .../xcschemes/BedtimeLiveActivity.xcscheme | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme diff --git a/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme b/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme deleted file mode 100644 index 69e6caa..0000000 --- a/Bedtime/Bedtime.xcodeproj/xcshareddata/xcschemes/BedtimeLiveActivity.xcscheme +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 389b9fb931ee15fe279177ed89f0e5574c4aaac5 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 14 Aug 2026 08:03:33 -0700 Subject: [PATCH 58/70] clearer name --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 4a6436f..e07d3d2 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -60,7 +60,7 @@ final class LiveActivityManager: ObservableObject { defer { isWorking = false } let now = Date() - let schedule = upcomingSchedule(for: recommendation, now: now) + let schedule = getTimes(for: recommendation, now: now) let isSleeping = now >= schedule.bedtime let startTime = Self.activity(withID: activeActivityID)?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) let state = BedtimeActivityAttributes.ContentState( @@ -115,7 +115,7 @@ final class LiveActivityManager: ObservableObject { guard isSupported, areActivitiesEnabled else { return } - let schedule = upcomingSchedule(for: recommendation, now: now) + 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) @@ -229,7 +229,7 @@ final class LiveActivityManager: ObservableObject { } } - private func upcomingSchedule( + private func getTimes( for recommendation: BedtimeRecommendation, now: Date = Date(), calendar: Calendar = .current From c2f3baebe8c003d31a6938a2406ce1da770781cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 04:59:54 +0000 Subject: [PATCH 59/70] Pre-schedule a queue of upcoming nights with staleness reminders Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 106 ++++++++++++------ .../BedtimeLiveActivity.swift | 47 ++++++++ .../Shared/BedtimeActivityAttributes.swift | 13 +++ 3 files changed, 131 insertions(+), 35 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index e07d3d2..aa2f855 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -129,7 +129,7 @@ final class LiveActivityManager: ObservableObject { } if #available(iOS 26, *) { - await scheduleActivity( + await scheduleUpcomingNights( recommendation: recommendation, durationStyle: durationStyle, schedule: schedule, @@ -138,50 +138,86 @@ final class LiveActivityManager: ObservableObject { } } - /// Hands the start over to the system so the card appears at the wind-down - /// time on its own. This is the only fully local way to get there: starting - /// one from a background wake throws `ActivityAuthorizationError.visibility`, - /// so every other route needs either a push server or a user-triggered intent. + /// 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 scheduleActivity( + private func scheduleUpcomingNights( recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle, schedule: (bedtime: Date, wakeTime: Date), startingAt start: Date ) async { - // remove any existing activities scheduled to start at different times. - // It is not possible to update the start time of a scheduled activity - for activity in Activity.activities where activity.activityState == .pending { - if activity.content.state.bedtime == schedule.bedtime { return } - await activity.end(nil, dismissalPolicy: .immediate) + 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 } + // Already queued up with tonight's exact bedtimes, so leave it alone + // rather than tearing down and rebuilding an unchanged queue. + let pendingBedtimes = Set(pending.map(\.content.state.bedtime)) + let desiredBedtimes = Set(projectedNights.map(\.bedtime)) + guard pendingBedtimes != desiredBedtimes else { return } - do { - let activity = try Activity.request( - attributes: BedtimeActivityAttributes(), - content: ActivityContent(state: state, staleDate: schedule.bedtime), - pushType: nil, - style: .standard, - alertConfiguration: AlertConfiguration( - title: "Time to wind down", - body: "Bedtime is coming up.", - sound: .default - ), - start: start + for activity in 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 ) - activeActivityID = activity.id - } catch { - lastErrorMessage = error.localizedDescription + + 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 + } + } 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 + } } } diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index f24f494..cbda1a0 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -127,6 +127,31 @@ private extension BedtimeActivityAttributes.ContentState { } } +/// 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. /// @@ -226,6 +251,12 @@ private struct BedtimeSummaryView: View { 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) + } } } @@ -271,6 +302,21 @@ extension BedtimeActivityAttributes.ContentState { isSleeping: 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()) { @@ -278,6 +324,7 @@ extension BedtimeActivityAttributes.ContentState { } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.staleQueuedSample } #Preview("Island Expanded", as: .dynamicIsland(.expanded), using: BedtimeActivityAttributes()) { diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 9b89bea..4d8cd81 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -4,6 +4,12 @@ import Foundation // ActivityKit hands this type to the widget extension across isolation // boundaries, so it must stay off the target's default MainActor isolation. nonisolated 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 @@ -15,5 +21,12 @@ nonisolated struct BedtimeActivityAttributes: ActivityAttributes { /// 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 } } From 49514011d3af3299d97be5d5cdfda08d2b6aecb5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 05:20:34 +0000 Subject: [PATCH 60/70] Flip Live Activity to a done phase after wake time HealthKit background delivery and a BGProcessingTask backup call LiveActivityManager.markAwakeIfNeeded(), which flips a new isAwake flag and clears staleDate on the existing active/stale activity without recomputing the recommendation. The widget adds a third phase for this, showing a link to the Oura app (or a generic open-app prompt) instead of a countdown once the person wakes up. staleDate is a one-shot latch already spent on the wind-down->sleeping transition, so a second transition needs the app to actually run and push a fresh update rather than a second local staleness flip. Co-authored-by: Greg --- Bedtime/Bedtime/BedtimeApp.swift | 6 + Bedtime/Bedtime/Constants.swift | 4 + Bedtime/Bedtime/ContentView.swift | 5 +- Bedtime/Bedtime/Models/HealthKitManager.swift | 6 + .../Bedtime/Models/LiveActivityManager.swift | 82 +++++++- .../BedtimeLiveActivity.swift | 183 ++++++++++++------ .../Shared/BedtimeActivityAttributes.swift | 21 ++ 7 files changed, 242 insertions(+), 65 deletions(-) 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 3fa94d4..582ffb3 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -23,6 +23,10 @@ nonisolated enum Constants { 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 4cda54a..cb4f0f2 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -249,7 +249,10 @@ struct ContentView: View { await liveActivityManager.syncWithSchedule( recommendation: recommendation, - durationStyle: durationStyle + durationStyle: durationStyle, + sourceAppLink: recentSourceAppLinks.first.map { + BedtimeSourceAppLink(name: $0.name, url: $0.destination) + } ) } } 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 index aa2f855..6f94d42 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -1,4 +1,5 @@ import ActivityKit +import BackgroundTasks import Combine import Foundation @@ -33,6 +34,22 @@ final class LiveActivityManager: ObservableObject { activeActivityID = 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. + static func registerBackgroundTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Constants.wakeRefreshTaskIdentifier, + using: nil + ) { task in + 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 @@ -43,7 +60,8 @@ final class LiveActivityManager: ObservableObject { func startOrUpdate( with recommendation: BedtimeRecommendation, - durationStyle: DurationDisplayStyle + durationStyle: DurationDisplayStyle, + sourceAppLink: BedtimeSourceAppLink? = nil ) async { guard isSupported else { lastErrorMessage = "Live Activities need iOS 18 or later." @@ -69,7 +87,8 @@ final class LiveActivityManager: ObservableObject { wakeTime: schedule.wakeTime, targetSleepHours: recommendation.targetSleepDuration, durationStyle: durationStyle, - isSleeping: isSleeping + 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, @@ -80,7 +99,11 @@ final class LiveActivityManager: ObservableObject { ) if let activity = Self.activity(withID: activeActivityID) { - if activity.activityState == .active { + // `.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. + if activity.activityState == .active || activity.activityState == .stale { await activity.update(content) return } @@ -97,11 +120,49 @@ final class LiveActivityManager: ObservableObject { 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.activity(withID: activeActivityID), + activity.activityState == .active || activity.activityState == .stale + 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)) + } + + /// 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. @@ -109,7 +170,8 @@ final class LiveActivityManager: ObservableObject { recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle, leadTime: TimeInterval = Constants.liveActivityLeadTime, - now: Date = Date() + now: Date = Date(), + sourceAppLink: BedtimeSourceAppLink? = nil ) async { await endFinishedActivities(now: now) @@ -124,7 +186,7 @@ final class LiveActivityManager: ObservableObject { // 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) + await startOrUpdate(with: recommendation, durationStyle: durationStyle, sourceAppLink: sourceAppLink) return } @@ -133,7 +195,8 @@ final class LiveActivityManager: ObservableObject { recommendation: recommendation, durationStyle: durationStyle, schedule: schedule, - startingAt: windowOpens + startingAt: windowOpens, + sourceAppLink: sourceAppLink ) } } @@ -153,7 +216,8 @@ final class LiveActivityManager: ObservableObject { recommendation: BedtimeRecommendation, durationStyle: DurationDisplayStyle, schedule: (bedtime: Date, wakeTime: Date), - startingAt start: Date + startingAt start: Date, + sourceAppLink: BedtimeSourceAppLink? = nil ) async { let calendar = Calendar.autoupdatingCurrent let activeCount = Activity.activities @@ -191,7 +255,8 @@ final class LiveActivityManager: ObservableObject { durationStyle: durationStyle, // Scheduled activities always begin ahead of bedtime. isSleeping: false, - nightsSinceLastSync: index + nightsSinceLastSync: index, + sourceAppLink: sourceAppLink ) do { @@ -209,6 +274,7 @@ final class LiveActivityManager: ObservableObject { ) if index == 0 { activeActivityID = activity.id + scheduleWakeRefreshTask(at: night.wakeTime) } } catch { if index == 0 { diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index cbda1a0..bc0b872 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -37,52 +37,71 @@ struct BedtimeLiveActivity: Widget { .lineLimit(1) } compactTrailing: { let phase = BedtimePhase(context) - VStack(spacing: 2) { - CountdownText(state: context.state, phase: phase) - .lineLimit(1) - .minimumScaleFactor(0.5) - - PhaseProgressBar(state: context.state, phase: phase) - .frame(height: 2) + if phase == .awake { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.indigo) + } else { + VStack(spacing: 2) { + CountdownText(state: context.state, phase: phase) + .lineLimit(1) + .minimumScaleFactor(0.5) + + 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. + .frame(maxWidth: 56) } - // Text doesn't hug so without a cap the region - // grows to fill the whole side of the status bar. - .frame(maxWidth: 56) } minimal: { let phase = BedtimePhase(context) - ProgressView( - timerInterval: context.state.progressRange(for: phase), - countsDown: phase == .windDown - ) { - EmptyView() - } currentValueLabel: { + if phase == .awake { Image(systemName: phase.symbol) - .padding(2) + .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) } - .progressViewStyle(.circular) - .tint(.indigo) } .keylineTint(.indigo) } } } -/// The activity covers two stretches of the night. `staleDate` is set to bedtime, -/// so the system re-renders into the sleeping phase without the app running. +/// 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) { - // 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 + 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" } } @@ -91,6 +110,7 @@ private enum BedtimePhase { switch self { case .windDown: "Bedtime" case .sleeping: "Wake" + case .awake: "Awake" } } @@ -100,6 +120,7 @@ private enum BedtimePhase { switch self { case .windDown: "Sleep" case .sleeping: "Wake" + case .awake: "Awake" } } } @@ -109,14 +130,14 @@ private extension BedtimeActivityAttributes.ContentState { func start(for phase: BedtimePhase) -> Date { switch phase { case .windDown: activityStart - case .sleeping: bedtime + case .sleeping, .awake: bedtime } } func target(for phase: BedtimePhase) -> Date { switch phase { case .windDown: bedtime - case .sleeping: wakeTime + case .sleeping, .awake: wakeTime } } @@ -216,47 +237,75 @@ private struct BedtimeSummaryView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 4) { - Image(systemName: phase.symbol) - .foregroundStyle(.indigo) - .padding(.trailing, 2) - - Text(phase.countdownLabel) + if phase == .awake { + awakeContent + } else { + countdownContent + } + } + } - CountdownText(state: state, phase: phase) - .fontWeight(.semibold) + private var awakeContent: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Good morning", systemImage: phase.symbol) + .font(.headline) + .foregroundStyle(.indigo) - Spacer(minLength: 0) + 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) } - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.7) + } + } - PhaseProgressBar(state: state, phase: phase) + @ViewBuilder + private var countdownContent: some View { + HStack(spacing: 4) { + Image(systemName: phase.symbol) + .foregroundStyle(.indigo) + .padding(.trailing, 2) - HStack(alignment: .firstTextBaseline) { - scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) + Text(phase.countdownLabel) - Spacer(minLength: 8) + CountdownText(state: state, phase: phase) + .fontWeight(.semibold) - VStack { - Text("Target") - .font(.caption) - .foregroundStyle(.secondary) - Text(targetText) - .font(.headline) - } + Spacer(minLength: 0) + } + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) - Spacer(minLength: 8) + PhaseProgressBar(state: state, phase: phase) - scheduleTime(label: "Wake", date: state.wakeTime, alignment: .trailing) - } + HStack(alignment: .firstTextBaseline) { + scheduleTime(label: "Bedtime", date: state.bedtime, alignment: .leading) + + Spacer(minLength: 8) - if let reminder = ReminderLevel(nightsSinceLastSync: state.nightsSinceLastSync) { - Label(reminder.message, systemImage: "arrow.triangle.2.circlepath") - .font(.caption2) - .foregroundStyle(reminder == .final ? .red : .secondary) + 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) } } @@ -303,6 +352,24 @@ extension BedtimeActivityAttributes.ContentState { ) } + /// 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 { @@ -324,6 +391,7 @@ extension BedtimeActivityAttributes.ContentState { } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample BedtimeActivityAttributes.ContentState.staleQueuedSample } @@ -332,6 +400,7 @@ extension BedtimeActivityAttributes.ContentState { } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample } #Preview("Island Compact", as: .dynamicIsland(.compact), using: BedtimeActivityAttributes()) { @@ -339,6 +408,7 @@ extension BedtimeActivityAttributes.ContentState { } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample } #Preview("Island Minimal", as: .dynamicIsland(.minimal), using: BedtimeActivityAttributes()) { @@ -346,5 +416,6 @@ extension BedtimeActivityAttributes.ContentState { } contentStates: { BedtimeActivityAttributes.ContentState.windDownSample BedtimeActivityAttributes.ContentState.sleepingSample + BedtimeActivityAttributes.ContentState.awakeSample } diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 4d8cd81..9bf0743 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -1,6 +1,14 @@ 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 +} + // ActivityKit hands this type to the widget extension across isolation // boundaries, so it must stay off the target's default MainActor isolation. nonisolated struct BedtimeActivityAttributes: ActivityAttributes { @@ -28,5 +36,18 @@ nonisolated struct BedtimeActivityAttributes: ActivityAttributes { /// 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 } } From d47fab5eb5b29ebf302d33cc330cb31e37cd139d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 03:57:55 +0000 Subject: [PATCH 61/70] Mark BedtimeSourceAppLink nonisolated to fix Equatable conformance Co-authored-by: Greg --- Bedtime/Shared/BedtimeActivityAttributes.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Bedtime/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 9bf0743..84a0ee4 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -4,7 +4,11 @@ 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 { +/// +/// Embedded in `ContentState`, so like `BedtimeActivityAttributes` this must +/// stay off the module's default MainActor isolation, or its `Equatable`/ +/// `Hashable` conformances become main-actor-isolated too. +nonisolated struct BedtimeSourceAppLink: Codable, Hashable { let name: String let url: URL } From 333393e4a081020280ab154cdf1c5dad10834ef4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:01:12 +0000 Subject: [PATCH 62/70] Fix Sendable warning ending pending activities in scheduleUpcomingNights Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 6f94d42..d0c1b98 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -235,14 +235,21 @@ final class LiveActivityManager: ObservableObject { return (nightStart, nightBedtime, nightWakeTime) } - let pending = Activity.activities.filter { $0.activityState == .pending } // Already queued up with tonight's exact bedtimes, so leave it alone // rather than tearing down and rebuilding an unchanged queue. - let pendingBedtimes = Set(pending.map(\.content.state.bedtime)) + let pendingBedtimes = Set( + Activity.activities + .filter { $0.activityState == .pending } + .map(\.content.state.bedtime) + ) let desiredBedtimes = Set(projectedNights.map(\.bedtime)) guard pendingBedtimes != desiredBedtimes else { return } - for activity in pending { + // 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) } From a17822f3e47ca0c5a9fae3fb36e47b03b4eb54b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:14:25 +0000 Subject: [PATCH 63/70] Flip default actor isolation from MainActor to nonisolated Removes SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor from the project's Debug/Release configs (shared by both targets) and the nonisolated markers that only existed to counteract it (Constants, SleepWindow, the TimeInterval extension, BedtimeActivityAttributes/ BedtimeSourceAppLink). Adds the two things that were actually relying on the implicit default rather than an explicit annotation: @MainActor on SourcePreferences (an ObservableObject, like HealthKitManager and LiveActivityManager), and nonisolated on LiveActivityManager.registerBackgroundTask() so it can still be called synchronously from BedtimeApp.init(), which is no longer MainActor-isolated by default. Co-authored-by: Greg --- Bedtime/Bedtime.xcodeproj/project.pbxproj | 2 -- Bedtime/Bedtime/Constants.swift | 2 +- Bedtime/Bedtime/Models/LiveActivityManager.swift | 6 ++++-- Bedtime/Bedtime/Models/SourcePreferences.swift | 1 + Bedtime/Bedtime/Utils/SleepWindow.swift | 2 +- Bedtime/Shared/BedtimeActivityAttributes.swift | 10 ++-------- Bedtime/Shared/TimeInterval.swift | 2 +- 7 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Bedtime/Bedtime.xcodeproj/project.pbxproj b/Bedtime/Bedtime.xcodeproj/project.pbxproj index 36d697b..ca350a0 100644 --- a/Bedtime/Bedtime.xcodeproj/project.pbxproj +++ b/Bedtime/Bedtime.xcodeproj/project.pbxproj @@ -345,7 +345,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -409,7 +408,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 582ffb3..1a82e55 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -8,7 +8,7 @@ import Foundation import SwiftUI -nonisolated enum Constants { +enum Constants { static let iconWidth: CGFloat = 30 static let cardHeaderSpacing: CGFloat = 12 static let sleepHistoryDays = 30 diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index d0c1b98..bce8554 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -37,8 +37,10 @@ final class LiveActivityManager: ObservableObject { /// 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. - static func registerBackgroundTask() { + /// 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 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/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/Shared/BedtimeActivityAttributes.swift b/Bedtime/Shared/BedtimeActivityAttributes.swift index 84a0ee4..c93cff2 100644 --- a/Bedtime/Shared/BedtimeActivityAttributes.swift +++ b/Bedtime/Shared/BedtimeActivityAttributes.swift @@ -4,18 +4,12 @@ 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. -/// -/// Embedded in `ContentState`, so like `BedtimeActivityAttributes` this must -/// stay off the module's default MainActor isolation, or its `Equatable`/ -/// `Hashable` conformances become main-actor-isolated too. -nonisolated struct BedtimeSourceAppLink: Codable, Hashable { +struct BedtimeSourceAppLink: Codable, Hashable { let name: String let url: URL } -// ActivityKit hands this type to the widget extension across isolation -// boundaries, so it must stay off the target's default MainActor isolation. -nonisolated struct BedtimeActivityAttributes: ActivityAttributes { +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 diff --git a/Bedtime/Shared/TimeInterval.swift b/Bedtime/Shared/TimeInterval.swift index 7c86eb8..be9e094 100644 --- a/Bedtime/Shared/TimeInterval.swift +++ b/Bedtime/Shared/TimeInterval.swift @@ -7,7 +7,7 @@ import Foundation -nonisolated extension TimeInterval { +extension TimeInterval { static func minutes(_ minutes: Int) -> TimeInterval { TimeInterval(60 * minutes) From 8304d92614848d5797bc8d20b96b7c8f6af3a62e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 06:20:42 +0000 Subject: [PATCH 64/70] Narrow compact trailing width and allow countdown text to wrap Co-authored-by: Greg --- .../BedtimeLiveActivity/BedtimeLiveActivity.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift index bc0b872..27713dc 100644 --- a/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift +++ b/Bedtime/BedtimeLiveActivity/BedtimeLiveActivity.swift @@ -43,15 +43,19 @@ struct BedtimeLiveActivity: Widget { } else { VStack(spacing: 2) { CountdownText(state: context.state, phase: phase) - .lineLimit(1) - .minimumScaleFactor(0.5) + .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. - .frame(maxWidth: 56) + // 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) From ba1ba2b5e926912ed0f559a1f133a78ecf2c3e04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 06:27:03 +0000 Subject: [PATCH 65/70] Sync Live Activity when the recommendation changes in the foreground refreshBedtimePlan() only ran on initial launch and returning from background, so adjusting the sleep bank range, sleep goal, or wake time while the app stayed foregrounded updated the on-screen recommendation but never told the Live Activity about it. Makes BedtimeRecommendation Equatable and adds .onChange(of:) so any foreground change to the computed recommendation re-syncs it. Co-authored-by: Greg --- Bedtime/Bedtime/ContentView.swift | 9 +++++++++ Bedtime/Bedtime/Models/SleepData.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index cb4f0f2..458776f 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -237,6 +237,15 @@ struct ContentView: View { 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 diff --git a/Bedtime/Bedtime/Models/SleepData.swift b/Bedtime/Bedtime/Models/SleepData.swift index 5fcc232..d8a5e7b 100644 --- a/Bedtime/Bedtime/Models/SleepData.swift +++ b/Bedtime/Bedtime/Models/SleepData.swift @@ -162,7 +162,7 @@ struct SleepBank: Equatable { } -struct BedtimeRecommendation { +struct BedtimeRecommendation: Equatable { let recommendedBedtime: Date let wakeTime: Date let targetSleepDuration: Double // in hours From 65387a7210057c224e40889d009a2ab0d4288c4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 06:30:07 +0000 Subject: [PATCH 66/70] Fix Live Activity updates targeting the wrong queued activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activeActivityID was cached from Activity.activities.first, which isn't guaranteed to be the on-screen activity once the multi-night queue can add several .pending future nights to that array. If it latched onto a pending night instead of tonight's active/stale one, startOrUpdate would look it up, find it .pending, end it (destroying a correctly queued future night), and create a brand-new separate activity — leaving the one actually on screen untouched. That's why neither the auto-sync nor the manual Update button appeared to do anything: they were updating something you couldn't see. Adds currentlyVisibleActivity(), which finds whatever's .active or .stale (on screen) directly rather than trusting the cached id, and uses it in init(), startOrUpdate(), and markAwakeIfNeeded(). Each of those also now writes activeActivityID back afterward, so the cache self-heals instead of staying wrong once it's drifted. Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index bce8554..ca7d5d9 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -31,7 +31,7 @@ final class LiveActivityManager: ObservableObject { } init() { - activeActivityID = Activity.activities.first?.id + activeActivityID = (Self.currentlyVisibleActivity() ?? Activity.activities.first)?.id } /// Registers the background refresh task handler. Apple requires this to @@ -82,7 +82,12 @@ final class LiveActivityManager: ObservableObject { let now = Date() let schedule = getTimes(for: recommendation, now: now) let isSleeping = now >= schedule.bedtime - let startTime = Self.activity(withID: activeActivityID)?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) + // 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. + let existingActivity = Self.currentlyVisibleActivity() ?? Self.activity(withID: activeActivityID) + let startTime = existingActivity?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) let state = BedtimeActivityAttributes.ContentState( activityStart: startTime, bedtime: schedule.bedtime, @@ -100,13 +105,14 @@ final class LiveActivityManager: ObservableObject { staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) - if let activity = Self.activity(withID: activeActivityID) { + if let activity = existingActivity { // `.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. if activity.activityState == .active || activity.activityState == .stale { await activity.update(content) + activeActivityID = activity.id return } // Anything else is scheduled but not on screen yet. Clear it so this @@ -135,10 +141,7 @@ final class LiveActivityManager: ObservableObject { /// (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.activity(withID: activeActivityID), - activity.activityState == .active || activity.activityState == .stale - else { return } + guard let activity = Self.currentlyVisibleActivity() else { return } let state = activity.content.state guard state.isSleeping, !state.isAwake, now >= state.wakeTime else { return } @@ -147,6 +150,7 @@ final class LiveActivityManager: ObservableObject { 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 @@ -340,6 +344,21 @@ final class LiveActivityManager: ObservableObject { } } + /// 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 + } + } + private func getTimes( for recommendation: BedtimeRecommendation, now: Date = Date(), From 2301f369b909cd5c9c89f746f4a4f91cb69833cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:22:02 +0000 Subject: [PATCH 67/70] Clear all pending activities before requesting a new one startOrUpdate only ended whichever single activity 'existingActivity' happened to resolve to before falling through to Activity.request(). Once the multi-night queue can leave several .pending nights scheduled at once, that left the others still counting against the 5-concurrent-activity budget, so request() failed with targetMaximumExceeded ('Maximum number of activities for target already exists') even though nothing was visibly on screen. Now ends every .pending activity before requesting a new one: starting/updating right now always takes priority over a background pre-scheduled queue, which syncWithSchedule rebuilds fresh anyway. Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index ca7d5d9..07860da 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -105,21 +105,29 @@ final class LiveActivityManager: ObservableObject { staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) - if let activity = existingActivity { + if let activity = existingActivity, 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. - if activity.activityState == .active || activity.activityState == .stale { - await activity.update(content) - activeActivityID = activity.id - return - } - // Anything else is scheduled but not on screen yet. Clear it so this - // request shows up now rather than silently editing tonight's plan. + 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. + for activity in Activity.activities where activity.activityState == .pending { await activity.end(nil, dismissalPolicy: .immediate) - activeActivityID = nil } + activeActivityID = nil do { let activity = try Activity.request( From 12dff3bd9d1b041db85e141f7ea8aea9a3ccfcd3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:43:04 +0000 Subject: [PATCH 68/70] Gate .pending cleanup loop in startOrUpdate behind iOS 26 Co-authored-by: Greg --- Bedtime/Bedtime/Models/LiveActivityManager.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 07860da..dcee34b 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -124,8 +124,13 @@ final class LiveActivityManager: ObservableObject { // 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. - for activity in Activity.activities where activity.activityState == .pending { - await activity.end(nil, dismissalPolicy: .immediate) + // + // `.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 From 4f23bd39fa6af0cf7680dc64a09e55b3fff43ed9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:47:54 +0000 Subject: [PATCH 69/70] Fix Sendable errors in registerBackgroundTask and startOrUpdate - registerBackgroundTask's launch handler captured the non-Sendable BGTask into a nested Task { @MainActor in }; nonisolated(unsafe) matches the existing HealthKit completionHandler workaround. - startOrUpdate's shared 'existingActivity' let was read at two separate points (computing startTime, then the if-let/await), which taints it for sending under strict concurrency. Replaced with a local function that re-derives it fresh at each call site, same fix as the earlier scheduleUpcomingNights issue. Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index dcee34b..41e5662 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -45,6 +45,10 @@ final class LiveActivityManager: ObservableObject { 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) @@ -86,8 +90,16 @@ final class LiveActivityManager: ObservableObject { // `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. - let existingActivity = Self.currentlyVisibleActivity() ?? Self.activity(withID: activeActivityID) - let startTime = existingActivity?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) + // + // Looked up fresh at each use below 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. + func existingActivity() -> Activity? { + Self.currentlyVisibleActivity() ?? Self.activity(withID: activeActivityID) + } + + let startTime = existingActivity()?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) let state = BedtimeActivityAttributes.ContentState( activityStart: startTime, bedtime: schedule.bedtime, @@ -105,7 +117,7 @@ final class LiveActivityManager: ObservableObject { staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) - if let activity = existingActivity, activity.activityState == .active || activity.activityState == .stale { + if let activity = existingActivity(), 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, From 00c3db781978827816e5d18b90015b6c7f9237a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 21:52:06 +0000 Subject: [PATCH 70/70] Use a proper nonisolated static helper for existingActivity lookup The previous fix used a local nested function, but local functions referencing self still inherit the enclosing method's MainActor isolation at their call site, which taints the returned Activity for the region just like a stored let did. Added existingActivity(cachedID:) as a real nonisolated static function (taking the cached ID as a parameter instead of reading self), matching the existing activity(withID:)/currentlyVisibleActivity() pattern, and call it fresh at each use site in startOrUpdate. Co-authored-by: Greg --- .../Bedtime/Models/LiveActivityManager.swift | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/Bedtime/Bedtime/Models/LiveActivityManager.swift b/Bedtime/Bedtime/Models/LiveActivityManager.swift index 41e5662..f8152ed 100644 --- a/Bedtime/Bedtime/Models/LiveActivityManager.swift +++ b/Bedtime/Bedtime/Models/LiveActivityManager.swift @@ -91,15 +91,13 @@ final class LiveActivityManager: ObservableObject { // 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 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. - func existingActivity() -> Activity? { - Self.currentlyVisibleActivity() ?? Self.activity(withID: activeActivityID) - } - - let startTime = existingActivity()?.content.state.activityStart ?? newStartTime(bedtime: schedule.bedtime, now: now) + // 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, @@ -117,7 +115,7 @@ final class LiveActivityManager: ObservableObject { staleDate: isSleeping ? schedule.wakeTime : schedule.bedtime ) - if let activity = existingActivity(), activity.activityState == .active || activity.activityState == .stale { + 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, @@ -384,6 +382,16 @@ final class LiveActivityManager: ObservableObject { } } + /// `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(),