diff --git a/SleepFocus.xcodeproj/project.pbxproj b/SleepFocus.xcodeproj/project.pbxproj index f9f1843..5fcb735 100644 --- a/SleepFocus.xcodeproj/project.pbxproj +++ b/SleepFocus.xcodeproj/project.pbxproj @@ -7,12 +7,34 @@ objects = { /* Begin PBXBuildFile section */ + 176448C12F821B47006C5284 /* SleepFocusWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 176448A12F821B44006C5284 /* SleepFocusWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 179829902F5F909F001D013C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1798298F2F5F909F001D013C /* WidgetKit.framework */; }; 179829922F5F909F001D013C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 179829912F5F909F001D013C /* SwiftUI.framework */; }; 179829A32F5F90A2001D013C /* WidgetSmartAlarmExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1798298D2F5F909F001D013C /* WidgetSmartAlarmExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 176448AE2F821B47006C5284 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A78A4FEF2F2ED24F00EDD8F4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 176448A02F821B44006C5284; + remoteInfo = "SleepFocusWatch Watch App"; + }; + 176448B82F821B47006C5284 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A78A4FEF2F2ED24F00EDD8F4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 176448A02F821B44006C5284; + remoteInfo = "SleepFocusWatch Watch App"; + }; + 176448BF2F821B47006C5284 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A78A4FEF2F2ED24F00EDD8F4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 176448A02F821B44006C5284; + remoteInfo = "SleepFocusWatch Watch App"; + }; 179829A12F5F90A2001D013C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A78A4FEF2F2ED24F00EDD8F4 /* Project object */; @@ -23,6 +45,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 176448C52F821B47006C5284 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 176448C12F821B47006C5284 /* SleepFocusWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 179829A42F5F90A2001D013C /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -37,6 +70,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 176448A12F821B44006C5284 /* SleepFocusWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SleepFocusWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 176448AD2F821B47006C5284 /* SleepFocusWatch Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SleepFocusWatch Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 176448B72F821B47006C5284 /* SleepFocusWatch Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SleepFocusWatch Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 1798298D2F5F909F001D013C /* WidgetSmartAlarmExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetSmartAlarmExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 1798298F2F5F909F001D013C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 179829912F5F909F001D013C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -55,6 +91,21 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 176448A22F821B44006C5284 /* SleepFocusWatch Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SleepFocusWatch Watch App"; + sourceTree = ""; + }; + 176448B02F821B47006C5284 /* SleepFocusWatch Watch AppTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SleepFocusWatch Watch AppTests"; + sourceTree = ""; + }; + 176448BA2F821B47006C5284 /* SleepFocusWatch Watch AppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "SleepFocusWatch Watch AppUITests"; + sourceTree = ""; + }; 179829932F5F909F001D013C /* WidgetSmartAlarm */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -71,6 +122,27 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 1764489E2F821B44006C5284 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448AA2F821B47006C5284 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448B42F821B47006C5284 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1798298A2F5F909F001D013C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -105,6 +177,9 @@ 17982CF82F5F9FE7001D013C /* SleepFocus-Info.plist */, A78A4FF92F2ED24F00EDD8F4 /* SleepFocus */, 179829932F5F909F001D013C /* WidgetSmartAlarm */, + 176448A22F821B44006C5284 /* SleepFocusWatch Watch App */, + 176448B02F821B47006C5284 /* SleepFocusWatch Watch AppTests */, + 176448BA2F821B47006C5284 /* SleepFocusWatch Watch AppUITests */, 1798298E2F5F909F001D013C /* Frameworks */, A78A4FF82F2ED24F00EDD8F4 /* Products */, ); @@ -115,6 +190,9 @@ children = ( A78A4FF72F2ED24F00EDD8F4 /* SleepFocus.app */, 1798298D2F5F909F001D013C /* WidgetSmartAlarmExtension.appex */, + 176448A12F821B44006C5284 /* SleepFocusWatch Watch App.app */, + 176448AD2F821B47006C5284 /* SleepFocusWatch Watch AppTests.xctest */, + 176448B72F821B47006C5284 /* SleepFocusWatch Watch AppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -122,6 +200,74 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 176448A02F821B44006C5284 /* SleepFocusWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 176448C22F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch App" */; + buildPhases = ( + 1764489D2F821B44006C5284 /* Sources */, + 1764489E2F821B44006C5284 /* Frameworks */, + 1764489F2F821B44006C5284 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 176448A22F821B44006C5284 /* SleepFocusWatch Watch App */, + ); + name = "SleepFocusWatch Watch App"; + packageProductDependencies = ( + ); + productName = "SleepFocusWatch Watch App"; + productReference = 176448A12F821B44006C5284 /* SleepFocusWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; + 176448AC2F821B47006C5284 /* SleepFocusWatch Watch AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 176448C62F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch AppTests" */; + buildPhases = ( + 176448A92F821B47006C5284 /* Sources */, + 176448AA2F821B47006C5284 /* Frameworks */, + 176448AB2F821B47006C5284 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 176448AF2F821B47006C5284 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 176448B02F821B47006C5284 /* SleepFocusWatch Watch AppTests */, + ); + name = "SleepFocusWatch Watch AppTests"; + packageProductDependencies = ( + ); + productName = "SleepFocusWatch Watch AppTests"; + productReference = 176448AD2F821B47006C5284 /* SleepFocusWatch Watch AppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 176448B62F821B47006C5284 /* SleepFocusWatch Watch AppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 176448C92F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch AppUITests" */; + buildPhases = ( + 176448B32F821B47006C5284 /* Sources */, + 176448B42F821B47006C5284 /* Frameworks */, + 176448B52F821B47006C5284 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 176448B92F821B47006C5284 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 176448BA2F821B47006C5284 /* SleepFocusWatch Watch AppUITests */, + ); + name = "SleepFocusWatch Watch AppUITests"; + packageProductDependencies = ( + ); + productName = "SleepFocusWatch Watch AppUITests"; + productReference = 176448B72F821B47006C5284 /* SleepFocusWatch Watch AppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 1798298C2F5F909F001D013C /* WidgetSmartAlarmExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 179829A82F5F90A2001D013C /* Build configuration list for PBXNativeTarget "WidgetSmartAlarmExtension" */; @@ -152,11 +298,13 @@ A78A4FF42F2ED24F00EDD8F4 /* Frameworks */, A78A4FF52F2ED24F00EDD8F4 /* Resources */, 179829A42F5F90A2001D013C /* Embed Foundation Extensions */, + 176448C52F821B47006C5284 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 179829A22F5F90A2001D013C /* PBXTargetDependency */, + 176448C02F821B47006C5284 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( A78A4FF92F2ED24F00EDD8F4 /* SleepFocus */, @@ -178,6 +326,17 @@ LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 2620; TargetAttributes = { + 176448A02F821B44006C5284 = { + CreatedOnToolsVersion = 26.2; + }; + 176448AC2F821B47006C5284 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 176448A02F821B44006C5284; + }; + 176448B62F821B47006C5284 = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 176448A02F821B44006C5284; + }; 1798298C2F5F909F001D013C = { CreatedOnToolsVersion = 26.2; }; @@ -202,11 +361,35 @@ targets = ( A78A4FF62F2ED24F00EDD8F4 /* SleepFocus */, 1798298C2F5F909F001D013C /* WidgetSmartAlarmExtension */, + 176448A02F821B44006C5284 /* SleepFocusWatch Watch App */, + 176448AC2F821B47006C5284 /* SleepFocusWatch Watch AppTests */, + 176448B62F821B47006C5284 /* SleepFocusWatch Watch AppUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 1764489F2F821B44006C5284 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448AB2F821B47006C5284 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448B52F821B47006C5284 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1798298B2F5F909F001D013C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -224,6 +407,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 1764489D2F821B44006C5284 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448A92F821B47006C5284 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 176448B32F821B47006C5284 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 179829892F5F909F001D013C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -241,6 +445,21 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 176448AF2F821B47006C5284 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 176448A02F821B44006C5284 /* SleepFocusWatch Watch App */; + targetProxy = 176448AE2F821B47006C5284 /* PBXContainerItemProxy */; + }; + 176448B92F821B47006C5284 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 176448A02F821B44006C5284 /* SleepFocusWatch Watch App */; + targetProxy = 176448B82F821B47006C5284 /* PBXContainerItemProxy */; + }; + 176448C02F821B47006C5284 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 176448A02F821B44006C5284 /* SleepFocusWatch Watch App */; + targetProxy = 176448BF2F821B47006C5284 /* PBXContainerItemProxy */; + }; 179829A22F5F90A2001D013C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 1798298C2F5F909F001D013C /* WidgetSmartAlarmExtension */; @@ -249,6 +468,169 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 176448C32F821B47006C5284 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = SleepFocus; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = "SleepFocusWatch Watch App/SleepFocusWatch.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SleepFocusWatch-Watch-App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = SleepFocus; + INFOPLIST_KEY_NSHealthShareUsageDescription = "SleepFocus reads Apple Watch heart rate during overnight monitoring to set your automatic smart alarm."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.sleepfocusapp.sleepfocus; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sleepfocusapp.sleepfocus.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + 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 = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + }; + name = Debug; + }; + 176448C42F821B47006C5284 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = SleepFocus; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = "SleepFocusWatch Watch App/SleepFocusWatch.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SleepFocusWatch-Watch-App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = SleepFocus; + INFOPLIST_KEY_NSHealthShareUsageDescription = "SleepFocus reads Apple Watch heart rate during overnight monitoring to set your automatic smart alarm."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.sleepfocusapp.sleepfocus; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sleepfocusapp.sleepfocus.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + 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 = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + }; + name = Release; + }; + 176448C72F821B47006C5284 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XRQ9Q6A9F9; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.sleepfocusapp.SleepFocusWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SleepFocusWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SleepFocusWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 176448C82F821B47006C5284 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XRQ9Q6A9F9; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.sleepfocusapp.SleepFocusWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SleepFocusWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SleepFocusWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; + 176448CA2F821B47006C5284 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XRQ9Q6A9F9; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.sleepfocusapp.SleepFocusWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "SleepFocusWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + 176448CB2F821B47006C5284 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = XRQ9Q6A9F9; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.sleepfocusapp.SleepFocusWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "SleepFocusWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; 179829A52F5F90A2001D013C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -535,6 +917,33 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 176448C22F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 176448C32F821B47006C5284 /* Debug */, + 176448C42F821B47006C5284 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 176448C62F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch AppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 176448C72F821B47006C5284 /* Debug */, + 176448C82F821B47006C5284 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 176448C92F821B47006C5284 /* Build configuration list for PBXNativeTarget "SleepFocusWatch Watch AppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 176448CA2F821B47006C5284 /* Debug */, + 176448CB2F821B47006C5284 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 179829A82F5F90A2001D013C /* Build configuration list for PBXNativeTarget "WidgetSmartAlarmExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/SleepFocus/Components/Home/HomeHeaderView.swift b/SleepFocus/Components/Home/HomeHeaderView.swift index 7e07da1..c049261 100644 --- a/SleepFocus/Components/Home/HomeHeaderView.swift +++ b/SleepFocus/Components/Home/HomeHeaderView.swift @@ -2,6 +2,7 @@ import SwiftUI struct HomeHeaderView: View { @Binding var selectedDate: Date + let maxSelectableDate: Date var isSyncing: Bool = false @State private var isShowingDatePicker = false @@ -45,7 +46,7 @@ struct HomeHeaderView: View { get: { selectedDate }, set: { selectedDate = Calendar.current.startOfDay(for: $0) } ), - in: ...Date(), + in: ...maxSelectableDate, displayedComponents: .date ) .datePickerStyle(.graphical) diff --git a/SleepFocus/Components/Home/HomeMainView.swift b/SleepFocus/Components/Home/HomeMainView.swift index a4ea29e..0377280 100644 --- a/SleepFocus/Components/Home/HomeMainView.swift +++ b/SleepFocus/Components/Home/HomeMainView.swift @@ -5,14 +5,25 @@ struct HomeMainView: View { @EnvironmentObject var authManager: AuthenticationManager @EnvironmentObject var sleepPreferences: SleepPreferencesStore @EnvironmentObject var notificationSettings: NotificationSettingsStore + @Environment(\.scenePhase) private var scenePhase @StateObject private var homeSummaryStore = HomeSummaryStore() @State private var selectedDate = Calendar.current.startOfDay(for: Date()) + @State private var hasDetectedSleepForToday = false + @State private var didUserSelectDate = false + @State private var cutoverReferenceDate = Date() var body: some View { ScrollView { VStack(spacing: 12) { HomeHeaderView( - selectedDate: $selectedDate, + selectedDate: Binding( + get: { selectedDate }, + set: { newValue in + didUserSelectDate = true + selectedDate = Calendar.current.startOfDay(for: newValue) + } + ), + maxSelectableDate: maxSelectableDate, isSyncing: homeSummaryStore.isSyncing || homeSummaryStore.isWaitingForModel ) @@ -28,7 +39,9 @@ struct HomeMainView: View { SmartAlarmCard( isEnabled: sleepPreferences.smartAlarmEnabled, isSmartAlarmMode: sleepPreferences.smartAlarmMode == .bedtime, - targetTime: sleepPreferences.currentSmartAlarmTriggerDisplay, + targetTime: sleepPreferences.smartAlarmMode == .automatic + ? sleepPreferences.automaticModePrimaryDisplay + : sleepPreferences.currentSmartAlarmTriggerDisplay, overrideAlarmTime: sleepPreferences.date(for: sleepPreferences.smartAlarmWakeTimeMinutes), onOverrideAlarmChange: { updatedDate in sleepPreferences.updateSmartAlarmWakeTime(updatedDate) @@ -59,9 +72,93 @@ struct HomeMainView: View { .background(Color.appBackground) .task(id: Calendar.current.startOfDay(for: selectedDate)) { await homeSummaryStore.load(using: authManager, for: selectedDate) + refreshCutoverState() await sleepPreferences.refreshSmartAlarmCycleGuidance(using: authManager) await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } + .task { + await runCutoverLoop() + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + refreshCutoverState() + } + } + + private var maxSelectableDate: Date { + let calendar = Calendar.current + let reference = cutoverReferenceDate + let todayStart = calendar.startOfDay(for: reference) + let noon = calendar.date(byAdding: .hour, value: 12, to: todayStart) ?? todayStart + if reference >= noon || hasDetectedSleepForToday { + return calendar.date(byAdding: .day, value: 1, to: todayStart) ?? todayStart + } + + return reference + } + + private func refreshCutoverState() { + cutoverReferenceDate = Date() + updateSleepDetectionSnapshotIfNeeded(reference: cutoverReferenceDate) + autoAdvanceToNextSessionDateIfNeeded(reference: cutoverReferenceDate) + } + + private func updateSleepDetectionSnapshotIfNeeded(reference: Date) { + let calendar = Calendar.current + let todayStart = calendar.startOfDay(for: reference) + + guard calendar.isDate(selectedDate, inSameDayAs: todayStart) else { + return + } + + if homeSummaryStore.hasLocalSleepData { + hasDetectedSleepForToday = true + sleepPreferences.noteLocalSleepDetected(reference: reference, calendar: calendar) + } + } + + private func autoAdvanceToNextSessionDateIfNeeded(reference: Date) { + guard !didUserSelectDate else { + return + } + + let calendar = Calendar.current + let todayStart = calendar.startOfDay(for: reference) + let noon = calendar.date(byAdding: .hour, value: 12, to: todayStart) ?? todayStart + + guard calendar.isDate(selectedDate, inSameDayAs: todayStart) else { + return + } + + guard reference >= noon || hasDetectedSleepForToday else { + return + } + + selectedDate = calendar.date(byAdding: .day, value: 1, to: todayStart) ?? todayStart + } + + private func runCutoverLoop() async { + while !Task.isCancelled { + await MainActor.run { + refreshCutoverState() + } + + let now = Date() + let calendar = Calendar.current + let todayStart = calendar.startOfDay(for: now) + let todayNoon = calendar.date(byAdding: .hour, value: 12, to: todayStart) ?? todayStart + let nextNoon = now < todayNoon + ? todayNoon + : (calendar.date(byAdding: .day, value: 1, to: todayNoon) ?? todayNoon.addingTimeInterval(24 * 3600)) + + let waitSeconds = max(nextNoon.timeIntervalSince(now), 5) + let waitNanoseconds = UInt64(waitSeconds * 1_000_000_000) + do { + try await Task.sleep(nanoseconds: waitNanoseconds) + } catch { + return + } + } } } diff --git a/SleepFocus/Models/AutoSmartAlarmModels.swift b/SleepFocus/Models/AutoSmartAlarmModels.swift new file mode 100644 index 0000000..6bac960 --- /dev/null +++ b/SleepFocus/Models/AutoSmartAlarmModels.swift @@ -0,0 +1,43 @@ +import Foundation + +enum AutoSmartAlarmStatus: String, Codable { + case scheduled + case noFit + case timeout + case unsupported + case permissionDenied +} + +struct AutoSmartAlarmConfig: Codable, Equatable { + let enabled: Bool + let mode: String + let preferredBedtimeMinutes: Int + let earliestWakeMinutes: Int + let finalWakeMinutes: Int + let timezoneIdentifier: String + let fallbackWakeTimeMinutes: Int + + var isAutomaticModeEnabled: Bool { + enabled && mode == "automatic" + } +} + +struct AutoSmartAlarmDecision: Codable, Equatable { + let status: AutoSmartAlarmStatus + let onsetTimestamp: TimeInterval? + let wakeTimestamp: TimeInterval? + let sentAtTimestamp: TimeInterval + + var onsetDate: Date? { + onsetTimestamp.map(Date.init(timeIntervalSince1970:)) + } + + var wakeDate: Date? { + wakeTimestamp.map(Date.init(timeIntervalSince1970:)) + } +} + +enum AutoSmartAlarmPayloadKey { + static let config = "autoSmartAlarm.config" + static let decision = "autoSmartAlarm.decision" +} diff --git a/SleepFocus/Models/SmartAlarmSharedModels.swift b/SleepFocus/Models/SmartAlarmSharedModels.swift index a589968..2e45041 100644 --- a/SleepFocus/Models/SmartAlarmSharedModels.swift +++ b/SleepFocus/Models/SmartAlarmSharedModels.swift @@ -35,7 +35,14 @@ enum SmartAlarmSharedConfig { enum SmartAlarmModeDisplay { static func title(for mode: String) -> String { - mode == "bedtime" ? "Smart Alarm" : "Ideal Bedtime" + switch mode { + case "bedtime": + return "Smart Alarm" + case "automatic": + return "Auto Smart Alarm" + default: + return "Ideal Bedtime" + } } } diff --git a/SleepFocus/Models/UserBehaviorProfile.swift b/SleepFocus/Models/UserBehaviorProfile.swift index fed7285..488cf8b 100644 --- a/SleepFocus/Models/UserBehaviorProfile.swift +++ b/SleepFocus/Models/UserBehaviorProfile.swift @@ -69,3 +69,5 @@ final class BehaviorProfileStore: ObservableObject { defaults.set(encoded, forKey: profileKey) } } + +typealias BehaviorStore = BehaviorProfileStore diff --git a/SleepFocus/Screens/ProfileScreen.swift b/SleepFocus/Screens/ProfileScreen.swift index 0572e18..d3a8521 100644 --- a/SleepFocus/Screens/ProfileScreen.swift +++ b/SleepFocus/Screens/ProfileScreen.swift @@ -277,6 +277,29 @@ struct WakeWindowScheduleScreen: View { VStack(alignment: .leading, spacing: 10) { LabeledContent("Default window", value: sleepPreferences.defaultWakeWindowDisplay) + + LabeledContent("Default earliest wake") { + DatePicker( + "", + selection: defaultEarliestWakeBinding, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() + } + + LabeledContent("Default latest wake") { + DatePicker( + "", + selection: defaultLatestWakeBinding, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.compact) + .labelsHidden() + } + + Divider() + LabeledContent("Window for \(selectedDateLabel)", value: sleepPreferences.wakeWindowDisplay(for: selectedDate)) Divider() @@ -327,6 +350,26 @@ struct WakeWindowScheduleScreen: View { Self.dateFormatter.string(from: selectedDate) } + private var defaultEarliestWakeBinding: Binding { + Binding( + get: { sleepPreferences.date(for: sleepPreferences.earliestWakeupMinutes) }, + set: { updatedDate in + sleepPreferences.updateEarliestWakeup(updatedDate) + syncSmartAlarmRuntime() + } + ) + } + + private var defaultLatestWakeBinding: Binding { + Binding( + get: { sleepPreferences.date(for: sleepPreferences.finalWakeupMinutes) }, + set: { updatedDate in + sleepPreferences.updateFinalWakeup(updatedDate) + syncSmartAlarmRuntime() + } + ) + } + private var earliestWakeBinding: Binding { Binding( get: { sleepPreferences.wakeWindowEarliestDate(for: selectedDate) }, @@ -452,11 +495,17 @@ struct SmartAlarmSettingsScreen: View { pendingSmartAlarmRefresh = false smartAlarmDisplayOpacity = 1.0 displayedSmartAlarmSchedule = "" - } else { + } else if sleepPreferences.smartAlarmMode == .bedtime { syncDisplayedSmartAlarmTrigger(force: true) beginSmartAlarmFetchTransition() pendingIdealBedtimeRefresh = false idealBedtimeDisplayOpacity = 1.0 + } else { + syncDisplayedSmartAlarmTrigger(force: true) + pendingIdealBedtimeRefresh = false + pendingSmartAlarmRefresh = false + idealBedtimeDisplayOpacity = 1.0 + smartAlarmDisplayOpacity = 1.0 } Task { await refreshGuidanceAndScheduleIfNeeded(forceRefresh: true) } } @@ -465,7 +514,7 @@ struct SmartAlarmSettingsScreen: View { beginIdealBedtimeFetchTransition() scheduleGuidanceRefreshAndSync() } else { - syncDisplayedSmartAlarmTrigger() + syncDisplayedSmartAlarmTrigger(force: sleepPreferences.smartAlarmMode == .automatic) Task { await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } } } @@ -490,26 +539,37 @@ struct SmartAlarmSettingsScreen: View { .onChange(of: sleepPreferences.sleepGoalHours) { _, _ in if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() - } else { + scheduleGuidanceRefreshAndSync() + } else if sleepPreferences.smartAlarmMode == .bedtime { beginSmartAlarmFetchTransition() + scheduleGuidanceRefreshAndSync() + } else { + Task { await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } } - scheduleGuidanceRefreshAndSync() } .onChange(of: sleepPreferences.earliestWakeupMinutes) { _, _ in if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() - } else { + scheduleGuidanceRefreshAndSync() + } else if sleepPreferences.smartAlarmMode == .bedtime { beginSmartAlarmFetchTransition() + scheduleGuidanceRefreshAndSync() + } else { + syncDisplayedSmartAlarmTrigger(force: true) + Task { await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } } - scheduleGuidanceRefreshAndSync() } .onChange(of: sleepPreferences.finalWakeupMinutes) { _, _ in if sleepPreferences.smartAlarmMode == .wake { beginIdealBedtimeFetchTransition() - } else { + scheduleGuidanceRefreshAndSync() + } else if sleepPreferences.smartAlarmMode == .bedtime { beginSmartAlarmFetchTransition() + scheduleGuidanceRefreshAndSync() + } else { + syncDisplayedSmartAlarmTrigger(force: true) + Task { await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } } - scheduleGuidanceRefreshAndSync() } } @@ -521,6 +581,8 @@ struct SmartAlarmSettingsScreen: View { return sleepPreferences.date(for: sleepPreferences.smartAlarmWakeTimeMinutes) case .bedtime: return sleepPreferences.date(for: sleepPreferences.smartAlarmBedtimeMinutes) + case .automatic: + return sleepPreferences.date(for: sleepPreferences.preferredBedtimeMinutes) } }, set: { updatedDate in @@ -530,6 +592,8 @@ struct SmartAlarmSettingsScreen: View { case .bedtime: beginSmartAlarmFetchTransition() sleepPreferences.updateSmartAlarmBedtime(updatedDate) + case .automatic: + sleepPreferences.updatePreferredBedtime(updatedDate) } } ) @@ -554,15 +618,31 @@ struct SmartAlarmSettingsScreen: View { private var earliestWakeBinding: Binding { Binding( - get: { sleepPreferences.date(for: sleepPreferences.earliestWakeupMinutes) }, - set: { sleepPreferences.updateEarliestWakeup($0) } + get: { sleepPreferences.wakeWindowEarliestDate(for: wakeWindowAnchorDay) }, + set: { updatedDate in + if sleepPreferences.smartAlarmMode == .wake { + beginIdealBedtimeFetchTransition() + } else { + beginSmartAlarmFetchTransition() + } + sleepPreferences.updateWakeWindowOverrideEarliest(for: wakeWindowAnchorDay, to: updatedDate) + scheduleGuidanceRefreshAndSync() + } ) } private var finalWakeBinding: Binding { Binding( - get: { sleepPreferences.date(for: sleepPreferences.finalWakeupMinutes) }, - set: { sleepPreferences.updateFinalWakeup($0) } + get: { sleepPreferences.wakeWindowLatestDate(for: wakeWindowAnchorDay) }, + set: { updatedDate in + if sleepPreferences.smartAlarmMode == .wake { + beginIdealBedtimeFetchTransition() + } else { + beginSmartAlarmFetchTransition() + } + sleepPreferences.updateWakeWindowOverrideLatest(for: wakeWindowAnchorDay, to: updatedDate) + scheduleGuidanceRefreshAndSync() + } ) } @@ -613,6 +693,15 @@ struct SmartAlarmSettingsScreen: View { title: "Mode", summary: sleepPreferences.smartAlarmMode.title ) { + modeOption( + icon: "moon.zzz.fill", + title: "Auto Smart Alarm", + subtitle: "Watch sets wake-up.", + isSelected: sleepPreferences.smartAlarmMode == .automatic + ) { + sleepPreferences.smartAlarmMode = .automatic + focusSection(.nextAlarm) + } modeOption( icon: "bed.double.fill", title: "Smart Alarm", @@ -658,6 +747,27 @@ struct SmartAlarmSettingsScreen: View { .datePickerStyle(.wheel) .labelsHidden() } + } else if sleepPreferences.smartAlarmMode == .automatic { + Text("Set the bedtime your watch should monitor around. Your wake window stays editable below.") + .font(.system(size: 12)) + .foregroundColor(.appSecondaryText) + + VStack(alignment: .leading, spacing: 12) { + Text("Preferred bedtime") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.appSecondaryText) + DatePicker( + "", + selection: inputTimeBinding, + displayedComponents: .hourAndMinute + ) + .datePickerStyle(.wheel) + .labelsHidden() + + Text(sleepPreferences.automaticModeStatusMessage ?? "Monitoring will arm on your Apple Watch.") + .font(.system(size: 12)) + .foregroundColor(.appSecondaryText) + } } else { Text("Set your bedtime, and we'll recommend your next alarm.") .font(.system(size: 12)) @@ -685,12 +795,16 @@ struct SmartAlarmSettingsScreen: View { section: .wakeWindow, icon: "sunrise.fill", title: "Allowed Wake Window", - summary: sleepPreferences.wakeWindowDisplay + summary: "\(wakeWindowAnchorLabel) • \(sleepPreferences.wakeWindowDisplay)" ) { Text("Smart Alarm keeps wake-ups inside this window.") .font(.system(size: 12)) .foregroundColor(.appSecondaryText) + Text("Applies to \(wakeWindowAnchorLabel).") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appSecondaryText) + HStack(spacing: 10) { wakeWindowChip(title: "Earliest wake", value: sleepPreferences.earliestWakeupDisplay) wakeWindowChip(title: "Latest wake", value: sleepPreferences.finalWakeupDisplay) @@ -719,6 +833,23 @@ struct SmartAlarmSettingsScreen: View { } .padding(.top, 4) + Button { + if sleepPreferences.smartAlarmMode == .wake { + beginIdealBedtimeFetchTransition() + } else { + beginSmartAlarmFetchTransition() + } + sleepPreferences.clearWakeWindowOverride(for: wakeWindowAnchorDay) + scheduleGuidanceRefreshAndSync() + } label: { + Text("Use Default Window for \(wakeWindowAnchorLabel)") + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Color(hex: "8E8E93")) + .disabled(!sleepPreferences.hasWakeWindowOverride(for: wakeWindowAnchorDay)) + } } @@ -756,6 +887,22 @@ struct SmartAlarmSettingsScreen: View { .foregroundColor(.appSecondaryText) } .opacity(idealBedtimeDisplayOpacity) + } else if sleepPreferences.smartAlarmMode == .automatic { + Text("Watch status") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.appSecondaryText) + + Text(sleepPreferences.automaticModePrimaryDisplay) + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.appPrimaryText) + + Text(sleepPreferences.automaticModeScheduleDisplay) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.appSecondaryText) + + Text(sleepPreferences.smartAlarmResultExplanation) + .font(.system(size: 12)) + .foregroundColor(.appSecondaryText) } else { Text("Next alarm") .font(.system(size: 13, weight: .semibold)) @@ -787,6 +934,9 @@ struct SmartAlarmSettingsScreen: View { if sleepPreferences.smartAlarmMode == .wake { return "Alarm \(sleepPreferences.timeDisplay(for: sleepPreferences.smartAlarmWakeTimeMinutes))" } + if sleepPreferences.smartAlarmMode == .automatic { + return "Monitor \(sleepPreferences.timeDisplay(for: sleepPreferences.preferredBedtimeMinutes))" + } return "Bedtime \(sleepPreferences.timeDisplay(for: sleepPreferences.smartAlarmBedtimeMinutes))" } @@ -819,13 +969,18 @@ struct SmartAlarmSettingsScreen: View { } private func syncDisplayedSmartAlarmTrigger(force: Bool = false) { - guard sleepPreferences.smartAlarmMode == .bedtime else { + guard sleepPreferences.smartAlarmMode != .wake else { return } if force || !pendingSmartAlarmRefresh { - displayedSmartAlarmTrigger = sleepPreferences.currentSmartAlarmTriggerDisplay - displayedSmartAlarmSchedule = nextTriggerDisplay + if sleepPreferences.smartAlarmMode == .automatic { + displayedSmartAlarmTrigger = sleepPreferences.automaticModePrimaryDisplay + displayedSmartAlarmSchedule = sleepPreferences.automaticModeScheduleDisplay + } else { + displayedSmartAlarmTrigger = sleepPreferences.currentSmartAlarmTriggerDisplay + displayedSmartAlarmSchedule = nextTriggerDisplay + } } } @@ -1002,6 +1157,21 @@ struct SmartAlarmSettingsScreen: View { return Self.dateTimeFormatter.string(from: nextTrigger) } + private var wakeWindowAnchorDay: Date { + sleepPreferences.nextWakeWindowDay + } + + private var wakeWindowAnchorLabel: String { + let calendar = Calendar.current + if calendar.isDateInTomorrow(wakeWindowAnchorDay) { + return "Tomorrow" + } + if calendar.isDateInToday(wakeWindowAnchorDay) { + return "Today" + } + return Self.dateFormatter.string(from: wakeWindowAnchorDay) + } + private func refreshGuidanceAndScheduleIfNeeded(forceRefresh: Bool = false) async { await sleepPreferences.refreshSmartAlarmCycleGuidance( using: authManager, @@ -1036,6 +1206,13 @@ struct SmartAlarmSettingsScreen: View { formatter.timeStyle = .short return formatter }() + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() } private struct SettingsDetailContainer: View { diff --git a/SleepFocus/Screens/SleepQualityDetailView.swift b/SleepFocus/Screens/SleepQualityDetailView.swift index e3dc883..097f77f 100644 --- a/SleepFocus/Screens/SleepQualityDetailView.swift +++ b/SleepFocus/Screens/SleepQualityDetailView.swift @@ -115,7 +115,7 @@ struct SleepQualityDetailView: View { get: { currentDate }, set: { currentDate = Calendar.current.startOfDay(for: $0) } ), - in: ...Date(), + in: ...max(Date(), currentDate), displayedComponents: .date ) .datePickerStyle(.graphical) diff --git a/SleepFocus/Services/HomeSummaryStore.swift b/SleepFocus/Services/HomeSummaryStore.swift index 739f766..088f0fe 100644 --- a/SleepFocus/Services/HomeSummaryStore.swift +++ b/SleepFocus/Services/HomeSummaryStore.swift @@ -109,10 +109,12 @@ final class HomeSummaryStore: ObservableObject { } } + let planningSleepDataReady = await isPlanningSleepDataReady(for: selectedDate) let onDemandTriggerResult = await triggerOnDemandPipelinesIfNeeded( using: authManager, for: selectedDate, - summary: resolvedSummary + summary: resolvedSummary, + planningSleepDataReady: planningSleepDataReady ) if onDemandTriggerResult.attemptedRecommendation { isRecommendationRefreshInFlight = true @@ -452,9 +454,6 @@ final class HomeSummaryStore: ObservableObject { let calendar = Calendar.current let selectedStart = calendar.startOfDay(for: selectedDate) let todayStart = calendar.startOfDay(for: Date()) - guard selectedStart <= todayStart else { - return false - } let status = summary.recommendationStatus?.lowercased() ?? "" if !hasReadyRecommendations(summary) || status == "loading" || status == "unavailable" { @@ -598,14 +597,19 @@ final class HomeSummaryStore: ObservableObject { private func triggerOnDemandPipelinesIfNeeded( using authManager: AuthenticationManager, for selectedDate: Date, - summary: HomeSummary? + summary: HomeSummary?, + planningSleepDataReady: Bool ) async -> OnDemandPipelineTriggerResult { guard let summary else { return .none } let shouldTriggerScore = shouldTriggerScorePipeline(for: selectedDate, summary: summary) - let recommendationTriggerSource = recommendationTriggerSource(for: selectedDate, summary: summary) + let recommendationTriggerSource = recommendationTriggerSource( + for: selectedDate, + summary: summary, + planningSleepDataReady: planningSleepDataReady + ) guard shouldTriggerScore || recommendationTriggerSource != nil else { return .none @@ -665,15 +669,27 @@ final class HomeSummaryStore: ObservableObject { return selectedStart >= thresholdDate } - private func recommendationTriggerSource(for selectedDate: Date, summary: HomeSummary) -> String? { + private func recommendationTriggerSource( + for selectedDate: Date, + summary: HomeSummary, + planningSleepDataReady: Bool + ) -> String? { let calendar = Calendar.current let selectedStart = calendar.startOfDay(for: selectedDate) let todayStart = calendar.startOfDay(for: Date()) - guard selectedStart <= todayStart else { - return nil + if selectedStart > todayStart { + guard let tomorrowStart = calendar.date(byAdding: .day, value: 1, to: todayStart), + selectedStart == tomorrowStart, + isPlanningCutoverSatisfied(reference: Date(), calendar: calendar, planningSleepDataReady: planningSleepDataReady) else { + return nil + } } - guard isRecommendationPrerequisiteReady(summary) else { + guard isRecommendationPrerequisiteReady( + selectedDate: selectedDate, + calendar: calendar, + planningSleepDataReady: planningSleepDataReady + ) else { return nil } @@ -693,8 +709,25 @@ final class HomeSummaryStore: ObservableObject { : "home_summary_missing_recommendation" } - private func isRecommendationPrerequisiteReady(_ summary: HomeSummary) -> Bool { - summary.sleepQuality.score != nil + private func isRecommendationPrerequisiteReady( + selectedDate: Date, + calendar: Calendar, + planningSleepDataReady: Bool + ) -> Bool { + let selectedStart = calendar.startOfDay(for: selectedDate) + let todayStart = calendar.startOfDay(for: Date()) + + if selectedStart <= todayStart { + // Don't wait on remote sleep scoring; local HealthKit sleep presence is enough to request output. + return hasLocalSleepData + } + + guard let tomorrowStart = calendar.date(byAdding: .day, value: 1, to: todayStart), + selectedStart == tomorrowStart else { + return false + } + + return isPlanningCutoverSatisfied(reference: Date(), calendar: calendar, planningSleepDataReady: planningSleepDataReady) } private func triggerRecommendationPipelineIfNeeded( @@ -703,7 +736,11 @@ final class HomeSummaryStore: ObservableObject { summary: HomeSummary? ) async -> Bool { guard let summary, - let triggerSource = recommendationTriggerSource(for: selectedDate, summary: summary) else { + let triggerSource = recommendationTriggerSource( + for: selectedDate, + summary: summary, + planningSleepDataReady: await isPlanningSleepDataReady(for: selectedDate) + ) else { return false } @@ -721,6 +758,34 @@ final class HomeSummaryStore: ObservableObject { } } + private func isPlanningCutoverSatisfied( + reference: Date, + calendar: Calendar, + planningSleepDataReady: Bool + ) -> Bool { + let todayStart = calendar.startOfDay(for: reference) + let noon = calendar.date(byAdding: .hour, value: 12, to: todayStart) ?? todayStart + return reference >= noon || planningSleepDataReady + } + + private func isPlanningSleepDataReady(for selectedDate: Date) async -> Bool { + let calendar = Calendar.current + let selectedStart = calendar.startOfDay(for: selectedDate) + let todayStart = calendar.startOfDay(for: Date()) + + guard selectedStart > todayStart, + let tomorrowStart = calendar.date(byAdding: .day, value: 1, to: todayStart), + selectedStart == tomorrowStart else { + return false + } + + async let metrics = fetchSleepMetrics(for: todayStart) + async let stages = fetchSleepStageBreakdown(for: todayStart) + let resolvedMetrics = await metrics + let resolvedStages = await stages + return resolvedMetrics != nil || (resolvedStages?.hasStageData ?? false) + } + private func postPipelineRun(_ payload: PipelineRunRequest, headers: [String: String]) async throws -> Bool { do { let _: PipelineRunResponse = try await APIWrapper.shared.post( diff --git a/SleepFocus/Services/PhoneSessionReceiver.swift b/SleepFocus/Services/PhoneSessionReceiver.swift new file mode 100644 index 0000000..686320f --- /dev/null +++ b/SleepFocus/Services/PhoneSessionReceiver.swift @@ -0,0 +1,129 @@ +import Combine +import Foundation +import WatchConnectivity + +@MainActor +final class PhoneSessionReceiver: NSObject, ObservableObject, WCSessionDelegate { + private let sleepPreferences: SleepPreferencesStore + private let notificationSettings: NotificationSettingsStore + private let session: WCSession? + private var pendingConfig: AutoSmartAlarmConfig? + + init( + sleepPreferences: SleepPreferencesStore, + notificationSettings: NotificationSettingsStore + ) { + self.sleepPreferences = sleepPreferences + self.notificationSettings = notificationSettings + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + self.session?.delegate = self + self.session?.activate() + } + + func syncConfiguration(timezone: TimeZone = .current) async { + let config = sleepPreferences.makeAutoSmartAlarmConfig(timezone: timezone) + pendingConfig = config + + guard let session else { + if config.isAutomaticModeEnabled { + await sleepPreferences.applyAutoSmartAlarmDecision( + AutoSmartAlarmDecision( + status: .unsupported, + onsetTimestamp: nil, + wakeTimestamp: nil, + sentAtTimestamp: Date().timeIntervalSince1970 + ), + notificationSettings: notificationSettings + ) + } + return + } + + guard !config.isAutomaticModeEnabled || (session.isPaired && session.isWatchAppInstalled) else { + await sleepPreferences.applyAutoSmartAlarmDecision( + AutoSmartAlarmDecision( + status: .unsupported, + onsetTimestamp: nil, + wakeTimestamp: nil, + sentAtTimestamp: Date().timeIntervalSince1970 + ), + notificationSettings: notificationSettings + ) + return + } + + sleepPreferences.clearAutoSmartAlarmDecisionIfStatus(in: [.unsupported, .permissionDenied]) + + guard session.activationState == .activated else { + session.activate() + return + } + + pushPendingConfigIfPossible() + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + guard activationState == .activated else { + return + } + + pushPendingConfigIfPossible() + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func sessionWatchStateDidChange(_ session: WCSession) { + pushPendingConfigIfPossible() + } + + func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + Task { await applyDecision(from: message) } + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + Task { await applyDecision(from: userInfo) } + } + + private func pushPendingConfigIfPossible() { + guard let session, + session.activationState == .activated, + let pendingConfig else { + return + } + + guard let encoded = try? JSONEncoder().encode(pendingConfig) else { + return + } + + do { + try session.updateApplicationContext([AutoSmartAlarmPayloadKey.config: encoded]) + } catch { + // Best effort. The watch can still use the last successful context. + } + } + + private func applyDecision(from payload: [String: Any]) async { + guard let data = payload[AutoSmartAlarmPayloadKey.decision] as? Data, + let decision = try? JSONDecoder().decode(AutoSmartAlarmDecision.self, from: data) else { + return + } + + await sleepPreferences.applyAutoSmartAlarmDecision( + decision, + notificationSettings: notificationSettings + ) + } +} diff --git a/SleepFocus/Services/SleepPreferencesStore.swift b/SleepFocus/Services/SleepPreferencesStore.swift index 0b4567d..dad5325 100644 --- a/SleepFocus/Services/SleepPreferencesStore.swift +++ b/SleepFocus/Services/SleepPreferencesStore.swift @@ -5,6 +5,7 @@ import Foundation enum SmartAlarmMode: String, CaseIterable, Codable { case wake case bedtime + case automatic var title: String { SmartAlarmModeDisplay.title(for: rawValue) @@ -16,6 +17,8 @@ enum SmartAlarmMode: String, CaseIterable, Codable { return "bed.double.fill" case .bedtime: return "alarm.fill" + case .automatic: + return "applewatch" } } } @@ -98,10 +101,14 @@ final class SleepPreferencesStore: ObservableObject { @Published private(set) var smartAlarmCycleGuidance: SmartAlarmCycleGuidancePayload? @Published private(set) var isRefreshingSmartAlarmGuidance = false @Published private(set) var smartAlarmLastErrorMessage: String? + @Published private(set) var autoSmartAlarmDecision: AutoSmartAlarmDecision? + @Published private(set) var planningClockToken: Int = 0 + @Published private(set) var lastDetectedSleepLocalDay: String? @Published private var wakeWindowOverridesByDay: [String: WakeWindowScheduleOverride] private var smartAlarmBedtimeAlarmIsManual: Bool private let defaults: UserDefaults + private var planningCutoverTask: Task? init(defaults: UserDefaults = .standard) { self.defaults = defaults @@ -138,6 +145,13 @@ final class SleepPreferencesStore: ObservableObject { } else { self.smartAlarmCycleGuidance = nil } + if let decisionData = defaults.data(forKey: Keys.autoSmartAlarmDecision), + let storedDecision = try? JSONDecoder().decode(AutoSmartAlarmDecision.self, from: decisionData) { + self.autoSmartAlarmDecision = storedDecision + } else { + self.autoSmartAlarmDecision = nil + } + self.lastDetectedSleepLocalDay = defaults.string(forKey: Keys.lastDetectedSleepLocalDay) defaults.set(self.earliestWakeupMinutes, forKey: Keys.smartAlarmEarliestWakeupMinutes) defaults.set(self.finalWakeupMinutes, forKey: Keys.smartAlarmFinalWakeupMinutes) @@ -148,6 +162,21 @@ final class SleepPreferencesStore: ObservableObject { } publishWidgetState(nextAlarmDate: nextSmartAlarmFireDate()) + schedulePlanningCutoverTicks() + } + + deinit { + planningCutoverTask?.cancel() + } + + func noteLocalSleepDetected(reference: Date = Date(), calendar: Calendar = .current) { + let localDay = Self.localDayFormatter.string(from: calendar.startOfDay(for: reference)) + guard lastDetectedSleepLocalDay != localDay else { + return + } + + lastDetectedSleepLocalDay = localDay + defaults.set(localDay, forKey: Keys.lastDetectedSleepLocalDay) } var snapshot: SleepPreferencesSnapshot { @@ -190,13 +219,28 @@ final class SleepPreferencesStore: ObservableObject { } var smartAlarmStatusDisplay: String { - smartAlarmEnabled ? currentSmartAlarmTriggerDisplay : "Off" + if !smartAlarmEnabled { + return "Off" + } + + if smartAlarmMode == .automatic, + let wakeDate = activeAutoSmartAlarmWakeDate { + return Self.timeFormatter.string(from: wakeDate) + } + + return currentSmartAlarmTriggerDisplay } var smartAlarmGuidanceHintText: String { if smartAlarmMode == .bedtime { return "Next alarm can be edited at any time." } + if smartAlarmMode == .automatic { + if let statusMessage = automaticModeStatusMessage { + return statusMessage + } + return "Your watch will monitor around \(timeDisplay(for: preferredBedtimeMinutes)) and set the wake time automatically." + } return "Ideal bedtime is personalized from your latest rhythm data." } @@ -210,6 +254,8 @@ final class SleepPreferencesStore: ObservableObject { return "Alarm time" case .bedtime: return "Bedtime" + case .automatic: + return "Preferred bedtime" } } @@ -219,6 +265,8 @@ final class SleepPreferencesStore: ObservableObject { return "Pick your alarm and get your ideal bedtime window." case .bedtime: return "Set bedtime and edit the next alarm as needed." + case .automatic: + return "Your watch monitors around bedtime and arms the wake-up automatically." } } @@ -226,6 +274,22 @@ final class SleepPreferencesStore: ObservableObject { if smartAlarmMode == .wake { return "Alarm time drives your bedtime recommendation." } + if smartAlarmMode == .automatic { + switch autoSmartAlarmDecision?.status { + case .scheduled: + return "Alarm set from watch-detected sleep onset." + case .noFit: + return "No cycle fit your wake window, so your existing alarm stays in place." + case .timeout: + return "Sleep onset was not confirmed before monitoring timed out." + case .unsupported: + return "Automatic mode needs a paired Apple Watch with health access enabled." + case .permissionDenied: + return "Allow Apple Watch heart-rate access to arm overnight monitoring." + case .none: + return "The watch will monitor around your preferred bedtime and pick the wake time for you." + } + } if hasFreshSmartAlarmPayload, hasMatchingProjectionInput, @@ -250,19 +314,83 @@ final class SleepPreferencesStore: ObservableObject { return timeDisplay(for: smartAlarmWakeTimeMinutes) case .bedtime: return timeDisplay(for: smartAlarmBedtimeMinutes) + case .automatic: + return timeDisplay(for: preferredBedtimeMinutes) + } + } + + var automaticModeStatusMessage: String? { + guard smartAlarmMode == .automatic else { + return nil + } + + switch autoSmartAlarmDecision?.status { + case .scheduled: + if let wakeDate = activeAutoSmartAlarmWakeDate { + return "Alarm set for \(Self.timeFormatter.string(from: wakeDate)) from your watch." + } + return "Your watch selected the next alarm automatically." + case .noFit: + return "No valid cycle fit your wake window. Your current alarm was left unchanged." + case .timeout: + return "Monitoring timed out before sleep onset was detected." + case .unsupported: + return "Automatic mode requires a paired Apple Watch with the companion app installed." + case .permissionDenied: + return "Open the watch app and allow heart-rate access to arm automatic monitoring." + case .none: + return "Monitoring around \(timeDisplay(for: preferredBedtimeMinutes)) on your Apple Watch." + } + } + + var automaticModePrimaryDisplay: String { + if let wakeDate = activeAutoSmartAlarmWakeDate { + return Self.timeFormatter.string(from: wakeDate) } + + return "Monitoring \(timeDisplay(for: preferredBedtimeMinutes))" + } + + var automaticModeScheduleDisplay: String { + if let wakeDate = activeAutoSmartAlarmWakeDate { + if Calendar.current.isDateInToday(wakeDate) { + return "Today at \(Self.timeFormatter.string(from: wakeDate))" + } + if Calendar.current.isDateInTomorrow(wakeDate) { + return "Tomorrow at \(Self.timeFormatter.string(from: wakeDate))" + } + return Self.dateTimeFormatter.string(from: wakeDate) + } + + return "Watch monitoring begins around \(timeDisplay(for: preferredBedtimeMinutes))" + } + + var autoSmartAlarmSyncToken: String { + [ + smartAlarmEnabled ? "1" : "0", + smartAlarmMode.rawValue, + "\(preferredBedtimeMinutes)", + "\(earliestWakeupMinutes)", + "\(finalWakeupMinutes)", + "\(smartAlarmWakeTimeMinutes)", + TimeZone.current.identifier + ].joined(separator: "|") } var defaultWakeWindowDisplay: String { "\(timeDisplay(for: earliestWakeupMinutes)) - \(timeDisplay(for: finalWakeupMinutes))" } + var nextWakeWindowDay: Date { + nextWakeWindowAnchorDay(reference: Date(), calendar: .current) + } + var earliestWakeupDisplay: String { - timeDisplay(for: wakeWindowMinutes(for: Date()).earliest) + timeDisplay(for: wakeWindowMinutes(for: nextWakeWindowDay).earliest) } var finalWakeupDisplay: String { - timeDisplay(for: wakeWindowMinutes(for: Date()).latest) + timeDisplay(for: wakeWindowMinutes(for: nextWakeWindowDay).latest) } var wakeWindowDisplay: String { @@ -406,27 +534,101 @@ final class SleepPreferencesStore: ObservableObject { } func toggleSmartAlarmMode() { - smartAlarmMode = smartAlarmMode == .wake ? .bedtime : .wake + let modes = SmartAlarmMode.allCases + guard let currentIndex = modes.firstIndex(of: smartAlarmMode) else { + smartAlarmMode = .wake + return + } + + smartAlarmMode = modes[(currentIndex + 1) % modes.count] } func nextSmartAlarmFireDate(reference: Date = Date(), calendar: Calendar = .current) -> Date? { guard smartAlarmEnabled else { return nil } + if smartAlarmMode == .automatic, + let wakeDate = activeAutoSmartAlarmWakeDate { + return wakeDate + } + let startOfToday = calendar.startOfDay(for: reference) - let todayTriggerMinutes = clampedSmartAlarmTriggerMinutes(for: reference) - guard let todayCandidate = calendar.date(byAdding: .minute, value: todayTriggerMinutes, to: startOfToday) else { + let anchorStart = planningAnchorStart(reference: reference, calendar: calendar) + let anchorTriggerMinutes = clampedSmartAlarmTriggerMinutes(for: anchorStart) + guard let anchorCandidate = calendar.date(byAdding: .minute, value: anchorTriggerMinutes, to: anchorStart) else { return nil } - if todayCandidate > reference { - return todayCandidate + if anchorCandidate > reference { + return anchorCandidate } - guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { + guard let nextDayStart = calendar.date(byAdding: .day, value: 1, to: anchorStart) else { return nil } - let tomorrowTriggerMinutes = clampedSmartAlarmTriggerMinutes(for: tomorrow) - return calendar.date(byAdding: .minute, value: tomorrowTriggerMinutes, to: tomorrow) + let nextTriggerMinutes = clampedSmartAlarmTriggerMinutes(for: nextDayStart) + return calendar.date(byAdding: .minute, value: nextTriggerMinutes, to: nextDayStart) + } + + func nextWakeWindowAnchorDay(reference: Date = Date(), calendar: Calendar = .current) -> Date { + let anchorStart = planningAnchorStart(reference: reference, calendar: calendar) + let triggerMinutes = clampedSmartAlarmTriggerMinutes(for: anchorStart) + if let candidate = calendar.date(byAdding: .minute, value: triggerMinutes, to: anchorStart), + candidate > reference { + return anchorStart + } + + return calendar.date(byAdding: .day, value: 1, to: anchorStart) ?? anchorStart + } + + private func schedulePlanningCutoverTicks() { + planningCutoverTask?.cancel() + planningCutoverTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + let now = Date() + let calendar = Calendar.current + let todayStart = calendar.startOfDay(for: now) + let todayNoon = calendar.date(byAdding: .hour, value: 12, to: todayStart) ?? todayStart + let nextNoon = now < todayNoon + ? todayNoon + : (calendar.date(byAdding: .day, value: 1, to: todayNoon) ?? todayNoon.addingTimeInterval(24 * 3600)) + let waitSeconds = max(nextNoon.timeIntervalSince(now), 5) + do { + try await Task.sleep(nanoseconds: UInt64(waitSeconds * 1_000_000_000)) + } catch { + return + } + + if Task.isCancelled { + return + } + + await MainActor.run { + self.planningClockToken &+= 1 + } + } + } + } + + private func planningAnchorStart(reference: Date, calendar: Calendar) -> Date { + let startOfToday = calendar.startOfDay(for: reference) + guard shouldAdvancePlanningAnchor(reference: reference, calendar: calendar), + let tomorrowStart = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { + return startOfToday + } + + return tomorrowStart + } + + private func shouldAdvancePlanningAnchor(reference: Date, calendar: Calendar) -> Bool { + let startOfToday = calendar.startOfDay(for: reference) + let noon = calendar.date(byAdding: .hour, value: 12, to: startOfToday) ?? startOfToday + if reference >= noon { + return true + } + + let localDay = Self.localDayFormatter.string(from: startOfToday) + return lastDetectedSleepLocalDay == localDay } func refreshSmartAlarmCycleGuidance( @@ -434,13 +636,19 @@ final class SleepPreferencesStore: ObservableObject { force: Bool = false, timezone: TimeZone = .current ) async { + guard smartAlarmMode != .automatic else { + smartAlarmLastErrorMessage = nil + return + } + if isRefreshingSmartAlarmGuidance { return } - let localDayKey = Self.localDayFormatter.string(from: Date()) + let anchorDay = nextWakeWindowAnchorDay(reference: Date()) + let localDayKey = Self.localDayFormatter.string(from: anchorDay) let lastRefreshDay = defaults.string(forKey: Keys.smartAlarmLastRefreshLocalDay) - let effectiveWindow = wakeWindowMinutes(for: Date()) + let effectiveWindow = wakeWindowMinutes(for: anchorDay) let sleepGoalMin = sleepGoalDurationMinutes let refreshContextKey = [ smartAlarmMode.rawValue, @@ -509,18 +717,60 @@ final class SleepPreferencesStore: ObservableObject { mode: smartAlarmMode ) if notificationSettings.liveSmartAlarmUpdatesEnabled { + let wakeWindowDisplays = wakeWindowDisplayComponents(for: nextAlarmDate) await SmartAlarmLiveActivityManager.startOrUpdate( nextAlarmDate: nextAlarmDate, displayTime: displayTime, mode: smartAlarmMode, - earliestWakeDisplay: earliestWakeupDisplay, - finalWakeDisplay: finalWakeupDisplay + earliestWakeDisplay: wakeWindowDisplays.earliest, + finalWakeDisplay: wakeWindowDisplays.latest ) } else { await SmartAlarmLiveActivityManager.endAll() } } + func makeAutoSmartAlarmConfig(timezone: TimeZone = .current) -> AutoSmartAlarmConfig { + AutoSmartAlarmConfig( + enabled: smartAlarmEnabled, + mode: smartAlarmMode.rawValue, + preferredBedtimeMinutes: preferredBedtimeMinutes, + earliestWakeMinutes: earliestWakeupMinutes, + finalWakeMinutes: finalWakeupMinutes, + timezoneIdentifier: timezone.identifier, + fallbackWakeTimeMinutes: Self.normalizeMinutes(smartAlarmWakeTimeMinutes) + ) + } + + func applyAutoSmartAlarmDecision( + _ decision: AutoSmartAlarmDecision, + notificationSettings: NotificationSettingsStore + ) async { + persistAutoSmartAlarmDecision(decision) + + guard smartAlarmMode == .automatic else { + return + } + + guard decision.status == .scheduled, + let wakeDate = decision.wakeDate, + wakeDate > Date() else { + return + } + + smartAlarmWakeTimeMinutes = Self.minutesSinceMidnight(from: wakeDate) + await syncSmartAlarmRuntime(notificationSettings: notificationSettings) + } + + func clearAutoSmartAlarmDecisionIfStatus(in statuses: Set) { + guard let status = autoSmartAlarmDecision?.status, + statuses.contains(status) else { + return + } + + persistAutoSmartAlarmDecision(nil) + } + static func date(for minutes: Int, calendar: Calendar = .current) -> Date { let normalized = normalizeMinutes(minutes) let hour = normalized / 60 @@ -539,11 +789,13 @@ final class SleepPreferencesStore: ObservableObject { return smartAlarmWakeTimeMinutes case .bedtime: return smartAlarmBedtimeMinutes + case .automatic: + return preferredBedtimeMinutes } } private var currentSmartAlarmTriggerMinutes: Int { - clampedSmartAlarmTriggerMinutes(for: Date()) + clampedSmartAlarmTriggerMinutes(for: nextWakeWindowDay) } private var isSmartAlarmTriggerAdjusted: Bool { @@ -561,6 +813,8 @@ final class SleepPreferencesStore: ObservableObject { return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) case .bedtime: return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) + case .automatic: + return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) } } @@ -812,6 +1066,28 @@ final class SleepPreferencesStore: ObservableObject { smartAlarmCycleGuidance } + private var activeAutoSmartAlarmWakeDate: Date? { + guard autoSmartAlarmDecision?.status == .scheduled, + let wakeDate = autoSmartAlarmDecision?.wakeDate, + wakeDate > Date() else { + return nil + } + + return wakeDate + } + + private func persistAutoSmartAlarmDecision(_ decision: AutoSmartAlarmDecision?) { + autoSmartAlarmDecision = decision + + guard let decision, + let encoded = try? JSONEncoder().encode(decision) else { + defaults.removeObject(forKey: Keys.autoSmartAlarmDecision) + return + } + + defaults.set(encoded, forKey: Keys.autoSmartAlarmDecision) + } + private static func minutesSinceMidnight(fromISO8601 isoDateString: String) -> Int? { if let date = iso8601FormatterWithFractionalSeconds.date(from: isoDateString) ?? iso8601Formatter.date(from: isoDateString) { @@ -843,19 +1119,32 @@ final class SleepPreferencesStore: ObservableObject { nextAlarmDisplay = "Off" } + let wakeWindowDisplays = wakeWindowDisplayComponents(for: nextAlarmDate) let state = SmartAlarmWidgetState( isEnabled: smartAlarmEnabled, mode: smartAlarmMode.rawValue, nextAlarmTimestamp: nextAlarmDate?.timeIntervalSince1970, nextAlarmDisplay: nextAlarmDisplay, - earliestWakeDisplay: earliestWakeupDisplay, - finalWakeDisplay: finalWakeupDisplay, + earliestWakeDisplay: wakeWindowDisplays.earliest, + finalWakeDisplay: wakeWindowDisplays.latest, updatedAtTimestamp: Date().timeIntervalSince1970 ) SmartAlarmWidgetBridge.writeState(state) } + private func wakeWindowDisplayComponents(for nextAlarmDate: Date?, calendar: Calendar = .current) -> (earliest: String, latest: String) { + let anchorDay: Date + if let nextAlarmDate { + anchorDay = calendar.startOfDay(for: nextAlarmDate) + } else { + anchorDay = nextWakeWindowAnchorDay(reference: Date(), calendar: calendar) + } + + let window = wakeWindowMinutes(for: anchorDay) + return (timeDisplay(for: window.earliest), timeDisplay(for: window.latest)) + } + private static func normalizeMinutes(_ minutes: Int) -> Int { ((minutes % 1440) + 1440) % 1440 } @@ -870,6 +1159,13 @@ final class SleepPreferencesStore: ObservableObject { return formatter }() + private static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + private static let localDayFormatter: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) @@ -903,9 +1199,11 @@ final class SleepPreferencesStore: ObservableObject { static let smartAlarmEarliestWakeupMinutes = "smartAlarm.earliestWakeupMinutes" static let smartAlarmFinalWakeupMinutes = "smartAlarm.finalWakeupMinutes" static let smartAlarmCycleGuidance = "smartAlarm.cycleGuidancePayload" + static let autoSmartAlarmDecision = "smartAlarm.autoSmartAlarmDecision" static let smartAlarmLastRefreshLocalDay = "smartAlarm.lastRefreshLocalDay" static let smartAlarmLastRefreshContext = "smartAlarm.lastRefreshContext" static let wakeWindowCalendarOverrides = "smartAlarm.wakeWindowCalendarOverrides" + static let lastDetectedSleepLocalDay = "sleepPreferences.lastDetectedSleepLocalDay" } private struct BedtimeRangeProjection { diff --git a/SleepFocus/SleepFocusApp.swift b/SleepFocus/SleepFocusApp.swift index a45285b..2adf266 100644 --- a/SleepFocus/SleepFocusApp.swift +++ b/SleepFocus/SleepFocusApp.swift @@ -2,15 +2,39 @@ import SwiftUI @main struct SleepFocusApp: App { - @StateObject private var navigationManager = NavigationManager() - @StateObject private var authManager = AuthenticationManager() - @StateObject private var sleepPreferences = SleepPreferencesStore() - @StateObject private var notificationSettings = NotificationSettingsStore() - @StateObject private var appearanceStore = AppearanceStore() - @StateObject private var behaviorStore = BehaviorProfileStore() - @StateObject private var insightsStore = InsightsStore() + @StateObject private var navigationManager: NavigationManager + @StateObject private var authManager: AuthenticationManager + @StateObject private var sleepPreferences: SleepPreferencesStore + @StateObject private var notificationSettings: NotificationSettingsStore + @StateObject private var phoneSessionReceiver: PhoneSessionReceiver + @StateObject private var appearanceStore: AppearanceStore + @StateObject private var insightsStore: InsightsStore + @StateObject private var behaviorStore: BehaviorStore private let healthAutoSyncService = HealthAutoSyncService.shared + init() { + let navigationManager = NavigationManager() + let authManager = AuthenticationManager() + let sleepPreferences = SleepPreferencesStore() + let notificationSettings = NotificationSettingsStore() + let phoneSessionReceiver = PhoneSessionReceiver( + sleepPreferences: sleepPreferences, + notificationSettings: notificationSettings + ) + let appearanceStore = AppearanceStore() + let insightsStore = InsightsStore() + let behaviorStore = BehaviorStore() + + _navigationManager = StateObject(wrappedValue: navigationManager) + _authManager = StateObject(wrappedValue: authManager) + _sleepPreferences = StateObject(wrappedValue: sleepPreferences) + _notificationSettings = StateObject(wrappedValue: notificationSettings) + _phoneSessionReceiver = StateObject(wrappedValue: phoneSessionReceiver) + _appearanceStore = StateObject(wrappedValue: appearanceStore) + _insightsStore = StateObject(wrappedValue: insightsStore) + _behaviorStore = StateObject(wrappedValue: behaviorStore) + } + var body: some Scene { WindowGroup { Group { @@ -52,6 +76,9 @@ struct SleepFocusApp: App { guard authManager.isAuthenticated else { return } await healthAutoSyncService.startIfNeeded(authManager: authManager) } + .task(id: sleepPreferences.autoSmartAlarmSyncToken) { + await phoneSessionReceiver.syncConfiguration(timezone: .current) + } .onOpenURL { url in navigationManager.handleDeepLink(url) } diff --git a/SleepFocusWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/SleepFocusWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SleepFocusWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SleepFocusWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/SleepFocusWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/SleepFocusWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SleepFocusWatch Watch App/Assets.xcassets/Contents.json b/SleepFocusWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SleepFocusWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SleepFocusWatch Watch App/AutoSmartAlarmModels.swift b/SleepFocusWatch Watch App/AutoSmartAlarmModels.swift new file mode 100644 index 0000000..6bac960 --- /dev/null +++ b/SleepFocusWatch Watch App/AutoSmartAlarmModels.swift @@ -0,0 +1,43 @@ +import Foundation + +enum AutoSmartAlarmStatus: String, Codable { + case scheduled + case noFit + case timeout + case unsupported + case permissionDenied +} + +struct AutoSmartAlarmConfig: Codable, Equatable { + let enabled: Bool + let mode: String + let preferredBedtimeMinutes: Int + let earliestWakeMinutes: Int + let finalWakeMinutes: Int + let timezoneIdentifier: String + let fallbackWakeTimeMinutes: Int + + var isAutomaticModeEnabled: Bool { + enabled && mode == "automatic" + } +} + +struct AutoSmartAlarmDecision: Codable, Equatable { + let status: AutoSmartAlarmStatus + let onsetTimestamp: TimeInterval? + let wakeTimestamp: TimeInterval? + let sentAtTimestamp: TimeInterval + + var onsetDate: Date? { + onsetTimestamp.map(Date.init(timeIntervalSince1970:)) + } + + var wakeDate: Date? { + wakeTimestamp.map(Date.init(timeIntervalSince1970:)) + } +} + +enum AutoSmartAlarmPayloadKey { + static let config = "autoSmartAlarm.config" + static let decision = "autoSmartAlarm.decision" +} diff --git a/SleepFocusWatch Watch App/AutoSmartAlarmSessionManager.swift b/SleepFocusWatch Watch App/AutoSmartAlarmSessionManager.swift new file mode 100644 index 0000000..f4bad99 --- /dev/null +++ b/SleepFocusWatch Watch App/AutoSmartAlarmSessionManager.swift @@ -0,0 +1,485 @@ +import Combine +import Foundation +import HealthKit +import WatchKit + +@MainActor +final class AutoSmartAlarmSessionManager: NSObject, ObservableObject, WKExtendedRuntimeSessionDelegate { + static let shared = AutoSmartAlarmSessionManager() + + @Published private(set) var statusText = "Automatic mode off" + @Published private(set) var detailText = "Open the iPhone app to enable Auto Smart Alarm." + @Published private(set) var wakeTimeDisplay = "--" + + private let defaults = UserDefaults.standard + private let connectivityManager = WatchConnectivityManager.shared + private let detector = SleepOnsetDetector() + private let healthStore = HKHealthStore() + + private var currentConfig: AutoSmartAlarmConfig? + private var currentSession: WKExtendedRuntimeSession? + private var currentSessionKind: SessionKind? + private var timeoutTask: Task? + private var didCompleteMonitoringFlow = false + + private override init() { + super.init() + connectivityManager.onConfigUpdate = { [weak self] config in + guard let self else { return } + self.applyConfiguration(config) + } + currentConfig = connectivityManager.latestConfig + refreshPublishedState() + } + + var latestDecision: AutoSmartAlarmDecision? { + connectivityManager.latestDecision + } + + func handleAppDidBecomeActive() { + refreshPublishedState() + guard let currentConfig, currentConfig.isAutomaticModeEnabled else { + return + } + + Task { + await armMonitoringIfNeeded(reschedule: false) + } + } + + func recover(extendedRuntimeSession: WKExtendedRuntimeSession) { + currentSession = extendedRuntimeSession + currentSession?.delegate = self + currentSessionKind = persistedSessionKind + + if currentSessionKind == .wake, extendedRuntimeSession.state == .running { + triggerWakeAlertIfNeeded() + } else if currentSessionKind == .monitoring, extendedRuntimeSession.state == .running { + startMonitoringRuntime() + } + refreshPublishedState() + } + + func startDebugMonitoringNow() { + guard let currentConfig else { + statusText = "Waiting for configuration" + detailText = "Enable Auto Smart Alarm on iPhone first." + return + } + + applyConfiguration(currentConfig, immediateStart: true) + } + + func simulateSleepOnsetNow() { + guard let config = currentConfig, config.isAutomaticModeEnabled else { + statusText = "Automatic mode off" + detailText = "Enable Auto Smart Alarm on iPhone first." + return + } + + handleDetectedSleepOnset(Date(), config: config) + } + + func applyConfiguration(_ config: AutoSmartAlarmConfig, immediateStart: Bool = false) { + let previousConfig = currentConfig + currentConfig = config + persistConfig(config) + + guard config.isAutomaticModeEnabled else { + stopAllSessions(clearWake: true) + statusText = "Automatic mode off" + detailText = "Your watch is not armed." + wakeTimeDisplay = "--" + return + } + + let shouldReschedule = if let previousConfig { + !Self.hasEquivalentArmingInputs(previousConfig, config) + } else { + true + } + + Task { + await armMonitoringIfNeeded( + reschedule: shouldReschedule, + immediateStart: immediateStart + ) + } + } + + func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) { + guard currentSession === extendedRuntimeSession else { + return + } + + switch currentSessionKind { + case .monitoring: + startMonitoringRuntime() + case .wake: + triggerWakeAlertIfNeeded() + case .none: + break + } + } + + func extendedRuntimeSessionWillExpire(_ extendedRuntimeSession: WKExtendedRuntimeSession) { + guard currentSession === extendedRuntimeSession else { + return + } + + if currentSessionKind == .monitoring, !didCompleteMonitoringFlow { + emitDecision(status: .timeout, onset: nil, wake: nil) + } + } + + func extendedRuntimeSession( + _ extendedRuntimeSession: WKExtendedRuntimeSession, + didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason, + error: Error? + ) { + guard currentSession === extendedRuntimeSession else { + return + } + + timeoutTask?.cancel() + timeoutTask = nil + + if currentSessionKind == .monitoring { + detector.stop() + if !didCompleteMonitoringFlow, + reason == .expired || reason == .suppressedBySystem { + emitDecision(status: .timeout, onset: nil, wake: nil) + } + } + + currentSession = nil + persistSessionKind(nil) + persistMonitoringStart(nil) + currentSessionKind = nil + refreshPublishedState() + } + + private func armMonitoringIfNeeded(reschedule: Bool, immediateStart: Bool = false) async { + guard let config = currentConfig, config.isAutomaticModeEnabled else { + return + } + + guard detector.isSupported else { + emitDecision(status: .unsupported, onset: nil, wake: nil) + return + } + + let authorizationGranted = await requestHeartRateAuthorization() + guard authorizationGranted else { + emitDecision(status: .permissionDenied, onset: nil, wake: nil) + return + } + + if !reschedule, + let currentSession, + currentSession.state != .invalid, + currentSessionKind == .wake, + let wakeDate = connectivityManager.latestDecision?.wakeDate, + wakeDate > Date() { + refreshPublishedState() + return + } + + if !reschedule, + let currentSession, + currentSession.state != .invalid, + currentSessionKind == .monitoring { + refreshPublishedState() + return + } + + stopAllSessions(clearWake: false) + + let monitoringSession = WKExtendedRuntimeSession() + monitoringSession.delegate = self + currentSession = monitoringSession + currentSessionKind = .monitoring + didCompleteMonitoringFlow = false + persistSessionKind(.monitoring) + + let startDate = nextMonitoringStartDate(for: config) + if immediateStart || startDate <= Date() { + monitoringSession.start() + statusText = "Monitoring now" + detailText = "Listening for sleep onset on your watch." + } else { + monitoringSession.start(at: startDate) + statusText = "Monitoring scheduled" + detailText = "Starts around \(Self.timeFormatter.string(from: startDate))." + } + + wakeTimeDisplay = "--" + } + + private func startMonitoringRuntime() { + guard let config = currentConfig else { + return + } + + persistMonitoringStart(Date()) + detector.start { [weak self] onsetDate in + Task { @MainActor in + self?.handleDetectedSleepOnset(onsetDate, config: config) + } + } + timeoutTask?.cancel() + timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Constants.monitoringTimeout * 1_000_000_000)) + guard !Task.isCancelled else { + return + } + await MainActor.run { + self?.handleMonitoringTimeout() + } + } + statusText = "Monitoring now" + detailText = "Waiting for stillness and a heart-rate drop." + refreshPublishedState() + } + + private func handleDetectedSleepOnset(_ onsetDate: Date, config: AutoSmartAlarmConfig) { + guard !didCompleteMonitoringFlow else { + return + } + + guard let wakeDate = Self.selectWakeDate(for: onsetDate, config: config) else { + didCompleteMonitoringFlow = true + detector.stop() + emitDecision(status: .noFit, onset: onsetDate, wake: nil) + currentSession?.invalidate() + statusText = "No valid wake time" + detailText = "The current alarm was left unchanged." + return + } + + didCompleteMonitoringFlow = true + detector.stop() + emitDecision(status: .scheduled, onset: onsetDate, wake: wakeDate) + wakeTimeDisplay = Self.timeFormatter.string(from: wakeDate) + currentSession?.invalidate() + scheduleWakeSession(for: wakeDate) + } + + private func handleMonitoringTimeout() { + guard !didCompleteMonitoringFlow else { + return + } + + didCompleteMonitoringFlow = true + detector.stop() + emitDecision(status: .timeout, onset: nil, wake: nil) + currentSession?.invalidate() + statusText = "Monitoring timed out" + detailText = "Your current alarm remains unchanged." + } + + private func scheduleWakeSession(for wakeDate: Date) { + let wakeSession = WKExtendedRuntimeSession() + wakeSession.delegate = self + currentSession = wakeSession + currentSessionKind = .wake + persistSessionKind(.wake) + persistMonitoringStart(nil) + wakeSession.start(at: wakeDate) + statusText = "Wake armed" + detailText = "Alarm set for \(Self.timeFormatter.string(from: wakeDate))." + } + + private func triggerWakeAlertIfNeeded() { + guard currentSessionKind == .wake else { + return + } + + currentSession?.notifyUser(hapticType: .notification, repeatHandler: nil) + statusText = "Wake firing" + detailText = "Wake haptics are active on your watch." + } + + private func stopAllSessions(clearWake: Bool) { + timeoutTask?.cancel() + timeoutTask = nil + detector.stop() + currentSession?.invalidate() + currentSession = nil + currentSessionKind = nil + persistSessionKind(nil) + persistMonitoringStart(nil) + if clearWake { + wakeTimeDisplay = "--" + } + } + + private func emitDecision(status: AutoSmartAlarmStatus, onset: Date?, wake: Date?) { + let decision = AutoSmartAlarmDecision( + status: status, + onsetTimestamp: onset?.timeIntervalSince1970, + wakeTimestamp: wake?.timeIntervalSince1970, + sentAtTimestamp: Date().timeIntervalSince1970 + ) + connectivityManager.sendDecision(decision) + refreshPublishedState() + } + + private func refreshPublishedState() { + if let wakeDate = connectivityManager.latestDecision?.wakeDate, + connectivityManager.latestDecision?.status == .scheduled, + wakeDate > Date() { + wakeTimeDisplay = Self.timeFormatter.string(from: wakeDate) + } else if wakeTimeDisplay.isEmpty { + wakeTimeDisplay = "--" + } + + guard let config = currentConfig else { + statusText = "Waiting for configuration" + detailText = "Enable Auto Smart Alarm on iPhone to arm the watch." + return + } + + guard config.isAutomaticModeEnabled else { + statusText = "Automatic mode off" + detailText = "Your watch is not armed." + return + } + + if statusText == "Automatic mode off" || statusText == "Waiting for configuration" { + statusText = "Ready" + detailText = "Monitoring will begin around \(Self.timeFormatter.string(from: nextMonitoringStartDate(for: config)))." + } + } + + private func nextMonitoringStartDate(for config: AutoSmartAlarmConfig, reference: Date = Date()) -> Date { + var calendar = Calendar.current + if let timezone = TimeZone(identifier: config.timezoneIdentifier) { + calendar.timeZone = timezone + } + + let monitorMinutes = Self.normalizeMinutes(config.preferredBedtimeMinutes - Constants.monitorLeadMinutes) + let startOfToday = calendar.startOfDay(for: reference) + let todayDate = calendar.date(byAdding: .minute, value: monitorMinutes, to: startOfToday) ?? reference + if todayDate > reference { + return todayDate + } + + let tomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday + return calendar.date(byAdding: .minute, value: monitorMinutes, to: tomorrow) ?? tomorrow + } + + private func requestHeartRateAuthorization() async -> Bool { + guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate) else { + return false + } + + return await withCheckedContinuation { continuation in + healthStore.requestAuthorization(toShare: nil, read: Set([heartRateType])) { success, _ in + continuation.resume(returning: success) + } + } + } + + private func persistConfig(_ config: AutoSmartAlarmConfig) { + guard let encoded = try? JSONEncoder().encode(config) else { + return + } + defaults.set(encoded, forKey: Keys.latestConfig) + } + + private var persistedSessionKind: SessionKind? { + guard let rawValue = defaults.string(forKey: Keys.sessionKind) else { + return nil + } + return SessionKind(rawValue: rawValue) + } + + private func persistSessionKind(_ kind: SessionKind?) { + if let kind { + defaults.set(kind.rawValue, forKey: Keys.sessionKind) + } else { + defaults.removeObject(forKey: Keys.sessionKind) + } + } + + private func persistMonitoringStart(_ date: Date?) { + if let date { + defaults.set(date.timeIntervalSince1970, forKey: Keys.monitoringStart) + } else { + defaults.removeObject(forKey: Keys.monitoringStart) + } + } + + static func selectWakeDate( + for onsetDate: Date, + config: AutoSmartAlarmConfig, + calendar inputCalendar: Calendar = .current + ) -> Date? { + var calendar = inputCalendar + if let timezone = TimeZone(identifier: config.timezoneIdentifier) { + calendar.timeZone = timezone + } + + let onsetMinutes = calendar.dateComponents([.hour, .minute], from: onsetDate) + let onsetMinutesSinceMidnight = (onsetMinutes.hour ?? 0) * 60 + (onsetMinutes.minute ?? 0) + let startOfOnsetDay = calendar.startOfDay(for: onsetDate) + let wakeWindowAnchorDay = onsetMinutesSinceMidnight >= config.preferredBedtimeMinutes + ? calendar.date(byAdding: .day, value: 1, to: startOfOnsetDay) ?? startOfOnsetDay + : startOfOnsetDay + let earliestWake = calendar.date(byAdding: .minute, value: config.earliestWakeMinutes, to: wakeWindowAnchorDay) + let finalWake = calendar.date(byAdding: .minute, value: config.finalWakeMinutes, to: wakeWindowAnchorDay) + + guard let earliestWake, let finalWake else { + return nil + } + + for cycleCount in stride(from: 6, through: 1, by: -1) { + let candidate = onsetDate.addingTimeInterval(Double(cycleCount) * Constants.sleepCycleDuration) + if candidate >= earliestWake, candidate <= finalWake { + return candidate + } + } + + return nil + } + + private static func normalizeMinutes(_ minutes: Int) -> Int { + ((minutes % 1440) + 1440) % 1440 + } + + private static func hasEquivalentArmingInputs( + _ lhs: AutoSmartAlarmConfig, + _ rhs: AutoSmartAlarmConfig + ) -> Bool { + lhs.enabled == rhs.enabled && + lhs.mode == rhs.mode && + lhs.preferredBedtimeMinutes == rhs.preferredBedtimeMinutes && + lhs.earliestWakeMinutes == rhs.earliestWakeMinutes && + lhs.finalWakeMinutes == rhs.finalWakeMinutes && + lhs.timezoneIdentifier == rhs.timezoneIdentifier + } + + private enum SessionKind: String { + case monitoring + case wake + } + + private enum Keys { + static let latestConfig = "watch.autoSmartAlarm.latestConfig" + static let sessionKind = "watch.autoSmartAlarm.sessionKind" + static let monitoringStart = "watch.autoSmartAlarm.monitoringStart" + } + + private enum Constants { + static let monitorLeadMinutes = 5 + static let monitoringTimeout: TimeInterval = 2 * 60 * 60 + static let sleepCycleDuration: TimeInterval = 90 * 60 + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter + }() +} diff --git a/SleepFocusWatch Watch App/ContentView.swift b/SleepFocusWatch Watch App/ContentView.swift new file mode 100644 index 0000000..76f005b --- /dev/null +++ b/SleepFocusWatch Watch App/ContentView.swift @@ -0,0 +1,53 @@ +// +// ContentView.swift +// SleepFocusWatch Watch App +// +// Created by Austin Kim on 4/4/26. +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject private var sessionManager: AutoSmartAlarmSessionManager + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "moon.zzz.fill") + .foregroundStyle(.pink) + Text("Auto Smart Alarm") + .font(.headline) + } + + VStack(alignment: .leading, spacing: 6) { + Text(sessionManager.statusText) + .font(.title3.bold()) + Text(sessionManager.detailText) + .font(.footnote) + .foregroundStyle(.secondary) + } + + LabeledContent("Wake", value: sessionManager.wakeTimeDisplay) + +#if DEBUG + VStack(spacing: 8) { + Button("Start Monitoring") { + sessionManager.startDebugMonitoringNow() + } + .buttonStyle(.borderedProminent) + + Button("Simulate Onset") { + sessionManager.simulateSleepOnsetNow() + } + .buttonStyle(.bordered) + } +#endif + } + .padding() + } +} + +#Preview { + ContentView() + .environmentObject(AutoSmartAlarmSessionManager.shared) +} diff --git a/SleepFocusWatch Watch App/SleepFocus.icon/Assets/sleep.png b/SleepFocusWatch Watch App/SleepFocus.icon/Assets/sleep.png new file mode 100644 index 0000000..10d1a08 Binary files /dev/null and b/SleepFocusWatch Watch App/SleepFocus.icon/Assets/sleep.png differ diff --git a/SleepFocusWatch Watch App/SleepFocus.icon/icon.json b/SleepFocusWatch Watch App/SleepFocus.icon/icon.json new file mode 100644 index 0000000..8608056 --- /dev/null +++ b/SleepFocusWatch Watch App/SleepFocus.icon/icon.json @@ -0,0 +1,61 @@ +{ + "fill-specializations" : [ + { + "value" : "system-light" + }, + { + "appearance" : "dark", + "value" : "system-dark" + } + ], + "groups" : [ + { + "blur-material" : 0.5, + "hidden" : false, + "layers" : [ + { + "glass-specializations" : [ + { + "value" : false + }, + { + "appearance" : "dark", + "value" : true + } + ], + "image-name-specializations" : [ + { + "value" : "sleep.png" + }, + { + "appearance" : "light", + "value" : "sleep.png" + } + ], + "name" : "sleep", + "position" : { + "scale" : 0.81, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/SleepFocusWatch Watch App/SleepFocusWatch.entitlements b/SleepFocusWatch Watch App/SleepFocusWatch.entitlements new file mode 100644 index 0000000..e10f430 --- /dev/null +++ b/SleepFocusWatch Watch App/SleepFocusWatch.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.healthkit + + + diff --git a/SleepFocusWatch Watch App/SleepFocusWatchApp.swift b/SleepFocusWatch Watch App/SleepFocusWatchApp.swift new file mode 100644 index 0000000..0321e0c --- /dev/null +++ b/SleepFocusWatch Watch App/SleepFocusWatchApp.swift @@ -0,0 +1,22 @@ +// +// SleepFocusWatchApp.swift +// SleepFocusWatch Watch App +// +// Created by Austin Kim on 4/4/26. +// + +import SwiftUI +import WatchKit + +@main +struct SleepFocusWatch_Watch_AppApp: App { + @WKApplicationDelegateAdaptor(WatchExtensionDelegate.self) private var extensionDelegate + @StateObject private var sessionManager = AutoSmartAlarmSessionManager.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(sessionManager) + } + } +} diff --git a/SleepFocusWatch Watch App/SleepOnsetDetector.swift b/SleepFocusWatch Watch App/SleepOnsetDetector.swift new file mode 100644 index 0000000..29a414a --- /dev/null +++ b/SleepFocusWatch Watch App/SleepOnsetDetector.swift @@ -0,0 +1,186 @@ +import CoreMotion +import Foundation +import HealthKit + +final class SleepOnsetDetector { + private let motionManager: CMMotionManager + private let healthStore: HKHealthStore + private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate) + + private var heartRateQuery: HKAnchoredObjectQuery? + private var monitoringStartDate: Date? + private var onsetHandler: ((Date) -> Void)? + private var didEmitOnset = false + + private var lastAccelerationMagnitude: Double? + private var motionSamples: [(date: Date, delta: Double)] = [] + private var baselineHeartRateSamples: [(date: Date, bpm: Double)] = [] + private var rollingHeartRateSamples: [(date: Date, bpm: Double)] = [] + + init( + motionManager: CMMotionManager = CMMotionManager(), + healthStore: HKHealthStore = HKHealthStore() + ) { + self.motionManager = motionManager + self.healthStore = healthStore + } + + var isSupported: Bool { + HKHealthStore.isHealthDataAvailable() && + motionManager.isAccelerometerAvailable && + heartRateType != nil + } + + func start(at date: Date = Date(), onsetHandler: @escaping (Date) -> Void) { + stop() + + monitoringStartDate = date + self.onsetHandler = onsetHandler + didEmitOnset = false + lastAccelerationMagnitude = nil + motionSamples = [] + baselineHeartRateSamples = [] + rollingHeartRateSamples = [] + + motionManager.accelerometerUpdateInterval = 1.0 + motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, _ in + self?.handleAccelerometerData(data) + } + + guard let heartRateType else { + return + } + + let query = HKAnchoredObjectQuery( + type: heartRateType, + predicate: nil, + anchor: nil, + limit: HKObjectQueryNoLimit + ) { [weak self] _, samples, _, _, _ in + self?.handleHeartRateSamples(samples) + } + query.updateHandler = { [weak self] _, samples, _, _, _ in + self?.handleHeartRateSamples(samples) + } + heartRateQuery = query + healthStore.execute(query) + } + + func stop() { + motionManager.stopAccelerometerUpdates() + if let heartRateQuery { + healthStore.stop(heartRateQuery) + } + heartRateQuery = nil + onsetHandler = nil + } + + private func handleAccelerometerData(_ data: CMAccelerometerData?) { + guard let data else { + return + } + + let magnitude = sqrt( + data.acceleration.x * data.acceleration.x + + data.acceleration.y * data.acceleration.y + + data.acceleration.z * data.acceleration.z + ) + let delta = abs(magnitude - (lastAccelerationMagnitude ?? magnitude)) + lastAccelerationMagnitude = magnitude + + let now = Date() + motionSamples.append((now, delta)) + motionSamples.removeAll { now.timeIntervalSince($0.date) > Constants.stillnessWindow } + evaluateOnset(at: now) + } + + private func handleHeartRateSamples(_ samples: [HKSample]?) { + guard let quantitySamples = samples as? [HKQuantitySample], !quantitySamples.isEmpty else { + return + } + + let unit = HKUnit.count().unitDivided(by: .minute()) + let now = Date() + for sample in quantitySamples { + let bpm = sample.quantity.doubleValue(for: unit) + baselineHeartRateSamples.append((sample.endDate, bpm)) + rollingHeartRateSamples.append((sample.endDate, bpm)) + } + + baselineHeartRateSamples.removeAll { + guard let monitoringStartDate else { return true } + return $0.date < monitoringStartDate || + $0.date.timeIntervalSince(monitoringStartDate) > Constants.baselineWindow + } + rollingHeartRateSamples.removeAll { now.timeIntervalSince($0.date) > Constants.heartRateWindow } + evaluateOnset(at: now) + } + + private func evaluateOnset(at date: Date) { + guard !didEmitOnset, + isStillnessConfirmed, + let baselineHeartRate, + let rollingMedianHeartRate, + rollingMedianHeartRate <= baselineHeartRate - Constants.requiredHeartRateDrop else { + return + } + + didEmitOnset = true + onsetHandler?(date) + } + + private var isStillnessConfirmed: Bool { + guard let earliestSampleDate = motionSamples.first?.date, + let latestSampleDate = motionSamples.last?.date, + latestSampleDate.timeIntervalSince(earliestSampleDate) >= Constants.stillnessWindow, + motionSamples.count >= 5 else { + return false + } + + let deltas = motionSamples.map(\.delta) + let mean = deltas.reduce(0, +) / Double(deltas.count) + let variance = deltas.reduce(0) { partialResult, value in + partialResult + pow(value - mean, 2) + } / Double(deltas.count) + return variance <= Constants.movementVarianceThreshold + } + + private var baselineHeartRate: Double? { + guard let monitoringStartDate, + Date().timeIntervalSince(monitoringStartDate) >= Constants.baselineWindow, + baselineHeartRateSamples.count >= 3 else { + return nil + } + + return Self.median(of: baselineHeartRateSamples.map(\.bpm)) + } + + private var rollingMedianHeartRate: Double? { + guard !rollingHeartRateSamples.isEmpty else { + return nil + } + + return Self.median(of: rollingHeartRateSamples.map(\.bpm)) + } + + private static func median(of values: [Double]) -> Double? { + guard !values.isEmpty else { + return nil + } + + let sorted = values.sorted() + let middleIndex = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2 + } + return sorted[middleIndex] + } + + private enum Constants { + static let stillnessWindow: TimeInterval = 5 * 60 + static let baselineWindow: TimeInterval = 10 * 60 + static let heartRateWindow: TimeInterval = 5 * 60 + static let requiredHeartRateDrop = 8.0 + static let movementVarianceThreshold = 0.0004 + } +} diff --git a/SleepFocusWatch Watch App/WatchConnectivityManager.swift b/SleepFocusWatch Watch App/WatchConnectivityManager.swift new file mode 100644 index 0000000..4a01849 --- /dev/null +++ b/SleepFocusWatch Watch App/WatchConnectivityManager.swift @@ -0,0 +1,116 @@ +import Foundation +import WatchConnectivity + +@MainActor +final class WatchConnectivityManager: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityManager() + + var onConfigUpdate: ((AutoSmartAlarmConfig) -> Void)? + + private let defaults = UserDefaults.standard + private let session: WCSession? + + private override init() { + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + session?.delegate = self + session?.activate() + } + + var latestConfig: AutoSmartAlarmConfig? { + guard let data = defaults.data(forKey: Keys.latestConfig), + let decoded = try? JSONDecoder().decode(AutoSmartAlarmConfig.self, from: data) else { + return nil + } + return decoded + } + + var latestDecision: AutoSmartAlarmDecision? { + guard let data = defaults.data(forKey: Keys.latestDecision), + let decoded = try? JSONDecoder().decode(AutoSmartAlarmDecision.self, from: data) else { + return nil + } + return decoded + } + + func sendDecision(_ decision: AutoSmartAlarmDecision) { + persistDecision(decision) + + guard let session else { + return + } + + guard let encoded = try? JSONEncoder().encode(decision) else { + return + } + + let payload: [String: Any] = [AutoSmartAlarmPayloadKey.decision: encoded] + if session.activationState == .activated, session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in + session.transferUserInfo(payload) + } + } else if session.activationState == .activated { + session.transferUserInfo(payload) + } else { + session.activate() + } + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + guard activationState == .activated else { + return + } + + if let data = session.receivedApplicationContext[AutoSmartAlarmPayloadKey.config] as? Data { + handleConfigData(data) + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + guard let data = applicationContext[AutoSmartAlarmPayloadKey.config] as? Data else { + return + } + + Task { @MainActor in + self.handleConfigData(data) + } + } + + private func handleConfigData(_ data: Data) { + guard let config = try? JSONDecoder().decode(AutoSmartAlarmConfig.self, from: data) else { + return + } + + persistConfig(config) + onConfigUpdate?(config) + } + + private func persistConfig(_ config: AutoSmartAlarmConfig) { + guard let encoded = try? JSONEncoder().encode(config) else { + return + } + + defaults.set(encoded, forKey: Keys.latestConfig) + } + + private func persistDecision(_ decision: AutoSmartAlarmDecision) { + guard let encoded = try? JSONEncoder().encode(decision) else { + return + } + + defaults.set(encoded, forKey: Keys.latestDecision) + } + + private enum Keys { + static let latestConfig = "watch.autoSmartAlarm.latestConfig" + static let latestDecision = "watch.autoSmartAlarm.latestDecision" + } +} diff --git a/SleepFocusWatch Watch App/WatchExtensionDelegate.swift b/SleepFocusWatch Watch App/WatchExtensionDelegate.swift new file mode 100644 index 0000000..ac9f14b --- /dev/null +++ b/SleepFocusWatch Watch App/WatchExtensionDelegate.swift @@ -0,0 +1,21 @@ +import WatchKit + +final class WatchExtensionDelegate: NSObject, WKApplicationDelegate { + func applicationDidBecomeActive() { + Task { @MainActor in + AutoSmartAlarmSessionManager.shared.handleAppDidBecomeActive() + } + } + + func handle(_ extendedRuntimeSession: WKExtendedRuntimeSession) { + Task { @MainActor in + AutoSmartAlarmSessionManager.shared.recover(extendedRuntimeSession: extendedRuntimeSession) + } + } + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + task.setTaskCompletedWithSnapshot(false) + } + } +} diff --git a/SleepFocusWatch Watch AppTests/SleepFocusWatch_Watch_AppTests.swift b/SleepFocusWatch Watch AppTests/SleepFocusWatch_Watch_AppTests.swift new file mode 100644 index 0000000..b1f24b4 --- /dev/null +++ b/SleepFocusWatch Watch AppTests/SleepFocusWatch_Watch_AppTests.swift @@ -0,0 +1,74 @@ +// +// SleepFocusWatch_Watch_AppTests.swift +// SleepFocusWatch Watch AppTests +// +// Created by Austin Kim on 4/4/26. +// + +import Foundation +import Testing +@testable import SleepFocusWatch_Watch_App + +@MainActor +struct SleepFocusWatch_Watch_AppTests { + + @Test func selectsLatestValidWakeDateWithinMorningWindow() async throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + + let config = AutoSmartAlarmConfig( + enabled: true, + mode: "automatic", + preferredBedtimeMinutes: 22 * 60 + 30, + earliestWakeMinutes: 6 * 60, + finalWakeMinutes: 9 * 60, + timezoneIdentifier: "America/Los_Angeles", + fallbackWakeTimeMinutes: 7 * 60 + ) + let onset = calendar.date(from: DateComponents(year: 2026, month: 4, day: 4, hour: 23, minute: 20))! + + let wakeDate = AutoSmartAlarmSessionManager.selectWakeDate(for: onset, config: config, calendar: calendar) + + #expect(wakeDate == calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8, minute: 20))) + } + + @Test func returnsNilWhenNoCycleFitsWindow() async throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + + let config = AutoSmartAlarmConfig( + enabled: true, + mode: "automatic", + preferredBedtimeMinutes: 22 * 60 + 30, + earliestWakeMinutes: 6 * 60, + finalWakeMinutes: 7 * 60, + timezoneIdentifier: "America/Los_Angeles", + fallbackWakeTimeMinutes: 7 * 60 + ) + let onset = calendar.date(from: DateComponents(year: 2026, month: 4, day: 4, hour: 23, minute: 50))! + + let wakeDate = AutoSmartAlarmSessionManager.selectWakeDate(for: onset, config: config, calendar: calendar) + + #expect(wakeDate == nil) + } + + @Test func usesSameMorningWindowForAfterMidnightOnset() async throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + + let config = AutoSmartAlarmConfig( + enabled: true, + mode: "automatic", + preferredBedtimeMinutes: 22 * 60 + 30, + earliestWakeMinutes: 6 * 60, + finalWakeMinutes: 9 * 60, + timezoneIdentifier: "America/Los_Angeles", + fallbackWakeTimeMinutes: 7 * 60 + ) + let onset = calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 0, minute: 30))! + + let wakeDate = AutoSmartAlarmSessionManager.selectWakeDate(for: onset, config: config, calendar: calendar) + + #expect(wakeDate == calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 8, minute: 0))) + } +} diff --git a/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITests.swift b/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITests.swift new file mode 100644 index 0000000..fc75526 --- /dev/null +++ b/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITests.swift @@ -0,0 +1,41 @@ +// +// SleepFocusWatch_Watch_AppUITests.swift +// SleepFocusWatch Watch AppUITests +// +// Created by Austin Kim on 4/4/26. +// + +import XCTest + +final class SleepFocusWatch_Watch_AppUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITestsLaunchTests.swift b/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITestsLaunchTests.swift new file mode 100644 index 0000000..7d025d2 --- /dev/null +++ b/SleepFocusWatch Watch AppUITests/SleepFocusWatch_Watch_AppUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// SleepFocusWatch_Watch_AppUITestsLaunchTests.swift +// SleepFocusWatch Watch AppUITests +// +// Created by Austin Kim on 4/4/26. +// + +import XCTest + +final class SleepFocusWatch_Watch_AppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/SleepFocusWatch-Watch-App-Info.plist b/SleepFocusWatch-Watch-App-Info.plist new file mode 100644 index 0000000..ca6f0aa --- /dev/null +++ b/SleepFocusWatch-Watch-App-Info.plist @@ -0,0 +1,12 @@ + + + + + UIBackgroundModes + + WKBackgroundModes + + alarm + + + diff --git a/WidgetSmartAlarm/SmartAlarmSharedModels.swift b/WidgetSmartAlarm/SmartAlarmSharedModels.swift index 3ddc422..3a58e82 100644 --- a/WidgetSmartAlarm/SmartAlarmSharedModels.swift +++ b/WidgetSmartAlarm/SmartAlarmSharedModels.swift @@ -34,7 +34,14 @@ enum SmartAlarmSharedConfig { enum SmartAlarmModeDisplay { static func title(for mode: String) -> String { - mode == "bedtime" ? "Smart Alarm" : "Ideal Bedtime" + switch mode { + case "bedtime": + return "Smart Alarm" + case "automatic": + return "Auto Smart Alarm" + default: + return "Ideal Bedtime" + } } }