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/Chat/ChatInputArea.swift b/SleepFocus/Components/Chat/ChatInputArea.swift index c29fb76..b079342 100644 --- a/SleepFocus/Components/Chat/ChatInputArea.swift +++ b/SleepFocus/Components/Chat/ChatInputArea.swift @@ -63,6 +63,6 @@ struct ChatInputArea: View { } .padding(.horizontal, 16) .padding(.vertical, 8) - .background(Color.appBackground) + .background(Color.appChatSurface) } } diff --git a/SleepFocus/Components/Chat/ChatMainView.swift b/SleepFocus/Components/Chat/ChatMainView.swift index 24f60c2..02adaf8 100644 --- a/SleepFocus/Components/Chat/ChatMainView.swift +++ b/SleepFocus/Components/Chat/ChatMainView.swift @@ -33,7 +33,7 @@ struct ChatMainView: View { } .padding(.top, 12) .padding(.bottom, 12) - .background(Color.appBackground) + .background(Color.appChatSurface) } .background(Color.appBackground) } diff --git a/SleepFocus/Components/Chat/ChatMessageView.swift b/SleepFocus/Components/Chat/ChatMessageView.swift index fa277ee..81db8b0 100644 --- a/SleepFocus/Components/Chat/ChatMessageView.swift +++ b/SleepFocus/Components/Chat/ChatMessageView.swift @@ -43,7 +43,7 @@ struct ChatMessageView: View { } private var backgroundColor: Color { - message.sender == .user ? .systemBlue : Color.appFieldBackground + message.sender == .user ? .systemBlue : .appChatSurface } private var alignment: Alignment { diff --git a/SleepFocus/Components/Chat/ChatViewModel.swift b/SleepFocus/Components/Chat/ChatViewModel.swift index 2056e58..265748d 100644 --- a/SleepFocus/Components/Chat/ChatViewModel.swift +++ b/SleepFocus/Components/Chat/ChatViewModel.swift @@ -91,6 +91,9 @@ final class ChatViewModel: ObservableObject { guard !Task.isCancelled, self.sessionID == sessionID else { return } + if result.invalidateThread { + threadId = nil + } if let resolvedThreadId = result.threadId, !resolvedThreadId.isEmpty { threadId = resolvedThreadId } @@ -109,6 +112,7 @@ final class ChatViewModel: ObservableObject { ) async -> ReplyEnvelope { let fallback = "I'm having trouble reaching the AI model right now, but I can still help summarize your recent sleep and focus trends." + let hasExistingThread = !(existingThreadId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) do { let accessToken = try await authManager.validAccessToken() @@ -127,10 +131,40 @@ final class ChatViewModel: ObservableObject { return ReplyEnvelope( replyText: reply.isEmpty ? fallback : reply, - threadId: resolvedThreadId + threadId: resolvedThreadId, + invalidateThread: false ) } catch { - return ReplyEnvelope(replyText: fallback, threadId: existingThreadId) + let recoveryAction = ChatAPIService.shared.threadRecoveryAction( + for: error, + hadExistingThread: hasExistingThread + ) + guard recoveryAction == .retryWithFreshThread else { + return ReplyEnvelope(replyText: fallback, threadId: existingThreadId, invalidateThread: false) + } + + do { + let accessToken = try await authManager.validAccessToken() + let freshThreadId = try await resolveThreadId( + accessToken: accessToken, + dayUtc: dayUtc, + existingThreadId: nil + ) + let retryReply = try await ChatAPIService.shared.sendMessage( + accessToken: accessToken, + threadId: freshThreadId, + message: text, + dayUtc: dayUtc + ) + + return ReplyEnvelope( + replyText: retryReply.isEmpty ? fallback : retryReply, + threadId: freshThreadId, + invalidateThread: false + ) + } catch { + return ReplyEnvelope(replyText: fallback, threadId: nil, invalidateThread: true) + } } } @@ -177,4 +211,5 @@ private struct QueuedMessage { private struct ReplyEnvelope { let replyText: String let threadId: String? + let invalidateThread: Bool } diff --git a/SleepFocus/Components/Chat/QuickPromptsView.swift b/SleepFocus/Components/Chat/QuickPromptsView.swift index f28cd7f..20e3d23 100644 --- a/SleepFocus/Components/Chat/QuickPromptsView.swift +++ b/SleepFocus/Components/Chat/QuickPromptsView.swift @@ -29,7 +29,7 @@ struct QuickPromptsView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 12) - .background(Color.appFieldBackground) + .background(Color.appChatSurface) .cornerRadius(16) .overlay( RoundedRectangle(cornerRadius: 16) @@ -40,6 +40,6 @@ struct QuickPromptsView: View { } } .padding(.horizontal, 16) - .background(Color.appBackground) + .background(Color.appChatSurface) } } diff --git a/SleepFocus/Components/Home/HomeMainView.swift b/SleepFocus/Components/Home/HomeMainView.swift index a4ea29e..98f1e5b 100644 --- a/SleepFocus/Components/Home/HomeMainView.swift +++ b/SleepFocus/Components/Home/HomeMainView.swift @@ -21,6 +21,7 @@ struct HomeMainView: View { maxScore: homeSummaryStore.summary?.sleepQuality.maxScore, sleepDuration: homeSummaryStore.sleepMetrics?.formattedAsleepDuration, status: homeSummaryStore.summary?.sleepQuality.status, + isNoData: homeSummaryStore.summary?.sleepQuality.source?.lowercased() == "no_healthkit_sleep", selectedDate: selectedDate, navigateTo: navigationManager.navigateTo ) @@ -28,7 +29,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) @@ -50,8 +53,6 @@ struct HomeMainView: View { selectedDate: selectedDate, navigateTo: navigationManager.navigateTo ) - - QuickActionsCard(navigateTo: navigationManager.navigateTo) } .padding(.horizontal, 16) .padding(.bottom, 20) diff --git a/SleepFocus/Components/Home/RecommendationsCard.swift b/SleepFocus/Components/Home/RecommendationsCard.swift index 93fba02..3e91c69 100644 --- a/SleepFocus/Components/Home/RecommendationsCard.swift +++ b/SleepFocus/Components/Home/RecommendationsCard.swift @@ -8,27 +8,36 @@ struct RecommendationsCard: View { let selectedDate: Date let navigateTo: (String, Any?) -> Void - private var actionTexts: [String] { + private var displayDetails: [RecommendationDetail] { if let recommendationDetails, !recommendationDetails.isEmpty { return recommendationDetails - .map(\.action) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + .filter { !$0.action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } return (recommendations ?? []) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .enumerated() + .map { index, action in + RecommendationDetail( + recommendationId: "fallback_\(index)", + action: action, + reason: "", + icon: nil, + tags: nil, + multiQuestion: nil, + response: nil + ) + } } private var displayRecommendations: [Recommendation] { - let icons = ["moon.zzz.fill", "sun.max.fill", "figure.walk"] let colors: [Color] = [.systemIndigo, .systemOrange, .systemGreen] - return actionTexts.prefix(3).enumerated().map { index, text in + return displayDetails.prefix(3).enumerated().map { index, detail in Recommendation( - icon: icons[index % icons.count], - text: text, + icon: Self.safeIcon(detail.icon), + text: detail.action, color: colors[index % colors.count] ) } @@ -153,6 +162,25 @@ struct RecommendationsCard: View { formatter.unitsStyle = .short return formatter }() + + private static func safeIcon(_ icon: String?) -> String { + let allowed = Set([ + "moon.zzz.fill", + "sun.max.fill", + "bed.double.fill", + "clock.fill", + "figure.walk", + "cup.and.saucer.fill", + "wind", + "thermometer", + "brain.head.profile", + "sparkles", + ]) + guard let icon, allowed.contains(icon) else { + return "sparkles" + } + return icon + } } private struct RecommendationsSkeletonView: View { diff --git a/SleepFocus/Components/Home/SleepQualityCard.swift b/SleepFocus/Components/Home/SleepQualityCard.swift index d680e39..8c162b2 100644 --- a/SleepFocus/Components/Home/SleepQualityCard.swift +++ b/SleepFocus/Components/Home/SleepQualityCard.swift @@ -5,6 +5,7 @@ struct SleepQualityCard: View { let maxScore: Double? let sleepDuration: String? let status: String? + let isNoData: Bool let selectedDate: Date let navigateTo: (String, Any?) -> Void @@ -82,6 +83,10 @@ struct SleepQualityCard: View { } private func getStatusText() -> String { + if isNoData { + return "No Sleep Data" + } + if let status, !status.isEmpty { return status } diff --git a/SleepFocus/Components/Settings/DeveloperSection.swift b/SleepFocus/Components/Settings/DeveloperSection.swift index b716d8f..9ea217b 100644 --- a/SleepFocus/Components/Settings/DeveloperSection.swift +++ b/SleepFocus/Components/Settings/DeveloperSection.swift @@ -5,8 +5,13 @@ struct DeveloperSection: View { @EnvironmentObject var notificationSettings: NotificationSettingsStore let navigateTo: (String, Any?) -> Void let clearLocalScoreFetchStorage: () -> Void + let clearRemoteSleepScoreArtifacts: () async throws -> Void @State private var showClearStorageConfirmation = false @State private var showClearStorageSuccess = false + @State private var showClearRemoteConfirmation = false + @State private var showClearRemoteSuccess = false + @State private var isClearingRemote = false + @State private var remoteClearErrorMessage: String? @State private var showTestAlarmScheduled = false var body: some View { @@ -50,6 +55,17 @@ struct DeveloperSection: View { action: { showClearStorageConfirmation = true } ) + Divider() + .padding(.leading, 60) + + SettingsRow( + title: isClearingRemote ? "Clearing Backend Sleep Score..." : "Clear Backend Sleep Score", + action: { + guard !isClearingRemote else { return } + showClearRemoteConfirmation = true + } + ) + if showTestAlarmScheduled { HStack(spacing: 8) { Image(systemName: "alarm.fill") @@ -77,6 +93,34 @@ struct DeveloperSection: View { .padding(.vertical, 12) .background(Color.appCardSecondaryBackground) } + + if showClearRemoteSuccess { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(hex: "34C759")) + Text("Backend sleep score and recommendations cleared.") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appSecondaryText) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appCardSecondaryBackground) + } + + if let remoteClearErrorMessage { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color(hex: "FF9500")) + Text(remoteClearErrorMessage) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appSecondaryText) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appCardSecondaryBackground) + } } .background(Color.appCardBackground) .cornerRadius(12) @@ -90,6 +134,14 @@ struct DeveloperSection: View { } message: { Text("This removes local sync state and HTTP cache used during score-fetch testing.") } + .alert("Clear backend sleep score?", isPresented: $showClearRemoteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Clear", role: .destructive) { + clearRemoteScoreArtifacts() + } + } message: { + Text("This keeps raw HealthKit data, but removes generated scoring, recommendation, chat-context, feedback, and pipeline artifacts so they can be rebuilt.") + } #else EmptyView() #endif @@ -112,6 +164,31 @@ struct DeveloperSection: View { } } + private func clearRemoteScoreArtifacts() { + Task { + await MainActor.run { + isClearingRemote = true + showClearRemoteSuccess = false + remoteClearErrorMessage = nil + } + + do { + try await clearRemoteSleepScoreArtifacts() + await MainActor.run { + showClearRemoteSuccess = true + } + } catch { + await MainActor.run { + remoteClearErrorMessage = error.localizedDescription + } + } + + await MainActor.run { + isClearingRemote = false + } + } + } + private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "h:mm:ss a" diff --git a/SleepFocus/Components/Settings/SettingsMainView.swift b/SleepFocus/Components/Settings/SettingsMainView.swift index 8bb2544..1cf42ec 100644 --- a/SleepFocus/Components/Settings/SettingsMainView.swift +++ b/SleepFocus/Components/Settings/SettingsMainView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsMainView: View { @EnvironmentObject var navigationManager: NavigationManager + @EnvironmentObject var authManager: AuthenticationManager @EnvironmentObject var notificationSettings: NotificationSettingsStore @EnvironmentObject var appearanceStore: AppearanceStore @StateObject private var healthAuth = HealthAuth() @@ -34,7 +35,8 @@ struct SettingsMainView: View { DeveloperSection( navigateTo: navigationManager.navigateTo, - clearLocalScoreFetchStorage: clearLocalScoreFetchStorage + clearLocalScoreFetchStorage: clearLocalScoreFetchStorage, + clearRemoteSleepScoreArtifacts: clearRemoteSleepScoreArtifacts ) SignOutSection() @@ -53,9 +55,12 @@ struct SettingsMainView: View { private func clearLocalScoreFetchStorage() { DeveloperStorageTools.clearScoreFetchStorage() } -} - + private func clearRemoteSleepScoreArtifacts() async throws { + try await DeveloperAPIService.clearSleepScoreArtifacts(authManager: authManager) + DeveloperStorageTools.clearHomeSummaryCache() + } +} diff --git a/SleepFocus/Extensions/ColorExtension.swift b/SleepFocus/Extensions/ColorExtension.swift index 73cf44e..fb43d16 100644 --- a/SleepFocus/Extensions/ColorExtension.swift +++ b/SleepFocus/Extensions/ColorExtension.swift @@ -48,6 +48,11 @@ extension Color { static let appSecondaryText = Color(uiColor: .secondaryLabel) static let appSeparator = Color(uiColor: .separator) static let appFieldBackground = Color(uiColor: .secondarySystemBackground) + static let appChatSurface = Color(uiColor: UIColor { traitCollection in + traitCollection.userInterfaceStyle == .dark + ? .secondarySystemBackground + : .white + }) static let appRecommendationHighlightStart = Color(uiColor: UIColor { traitCollection in traitCollection.userInterfaceStyle == .dark ? UIColor(red: 0.27, green: 0.24, blue: 0.15, alpha: 1.0) @@ -95,6 +100,5 @@ extension Color { - diff --git a/SleepFocus/Models/AutoSmartAlarmModels.swift b/SleepFocus/Models/AutoSmartAlarmModels.swift new file mode 100644 index 0000000..f05476a --- /dev/null +++ b/SleepFocus/Models/AutoSmartAlarmModels.swift @@ -0,0 +1,44 @@ +import Foundation + +enum AutoSmartAlarmStatus: String, Codable { + case scheduled + case noFit + case timeout + case unsupported + case permissionDenied + case backgroundPermissionDenied +} + +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/HomeSummary.swift b/SleepFocus/Models/HomeSummary.swift index b35c540..c8dfcf8 100644 --- a/SleepFocus/Models/HomeSummary.swift +++ b/SleepFocus/Models/HomeSummary.swift @@ -36,6 +36,8 @@ nonisolated struct RecommendationDetail: Codable, Hashable, Identifiable { let recommendationId: String let action: String let reason: String + let icon: String? + let tags: [String]? let multiQuestion: RecommendationMultiQuestion? let response: RecommendationQuestionResponse? 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..00c9626 100644 --- a/SleepFocus/Screens/ProfileScreen.swift +++ b/SleepFocus/Screens/ProfileScreen.swift @@ -452,11 +452,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 +471,7 @@ struct SmartAlarmSettingsScreen: View { beginIdealBedtimeFetchTransition() scheduleGuidanceRefreshAndSync() } else { - syncDisplayedSmartAlarmTrigger() + syncDisplayedSmartAlarmTrigger(force: sleepPreferences.smartAlarmMode == .automatic) Task { await sleepPreferences.syncSmartAlarmRuntime(notificationSettings: notificationSettings) } } } @@ -490,26 +496,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 +538,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 +549,8 @@ struct SmartAlarmSettingsScreen: View { case .bedtime: beginSmartAlarmFetchTransition() sleepPreferences.updateSmartAlarmBedtime(updatedDate) + case .automatic: + sleepPreferences.updatePreferredBedtime(updatedDate) } } ) @@ -613,6 +634,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 +688,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)) @@ -756,6 +807,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 +854,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 +889,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 + } } } diff --git a/SleepFocus/Screens/RecommendationsDetailView.swift b/SleepFocus/Screens/RecommendationsDetailView.swift index 24c6c6a..60055e6 100644 --- a/SleepFocus/Screens/RecommendationsDetailView.swift +++ b/SleepFocus/Screens/RecommendationsDetailView.swift @@ -9,6 +9,10 @@ struct RecommendationsDetailView: View { @State private var localResponsesByRecommendationID: [String: RecommendationQuestionResponse] = [:] @State private var submittingRecommendationIDs = Set() @State private var submitErrorMessage: String? + #if DEBUG + @State private var isRefreshingRecommendations = false + @State private var refreshErrorMessage: String? + #endif init(selectedDate: Date) { _currentDate = State(initialValue: Calendar.current.startOfDay(for: selectedDate)) @@ -37,6 +41,12 @@ struct RecommendationsDetailView: View { ForEach(recommendationItems) { detail in recommendationCard(for: detail) } + + Text("AI recommendations may be incorrect. Use your judgment and seek professional advice for health concerns.") + .font(.system(size: 12)) + .foregroundColor(.appSecondaryText) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) } } .padding(16) @@ -44,6 +54,26 @@ struct RecommendationsDetailView: View { } .navigationTitle("Recommendations") .navigationBarTitleDisplayMode(.inline) + #if DEBUG + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await refreshRecommendations() + } + } label: { + if isRefreshingRecommendations || homeSummaryStore.isRecommendationRefreshInFlight { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshingRecommendations || homeSummaryStore.isRecommendationRefreshInFlight) + .accessibilityLabel("Refresh recommendations") + } + } + #endif .task(id: Calendar.current.startOfDay(for: currentDate)) { await homeSummaryStore.load(using: authManager, for: currentDate) } @@ -59,6 +89,20 @@ struct RecommendationsDetailView: View { } message: { Text(submitErrorMessage ?? "Please try again.") } + #if DEBUG + .alert("Unable to Refresh", isPresented: Binding( + get: { refreshErrorMessage != nil }, + set: { shouldPresent in + if !shouldPresent { + refreshErrorMessage = nil + } + } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(refreshErrorMessage ?? "Please try again.") + } + #endif } @ViewBuilder @@ -169,6 +213,8 @@ struct RecommendationsDetailView: View { recommendationId: "fallback_\(index)", action: action, reason: "", + icon: nil, + tags: nil, multiQuestion: nil, response: nil ) @@ -258,6 +304,22 @@ struct RecommendationsDetailView: View { } } + #if DEBUG + private func refreshRecommendations() async { + guard !isRefreshingRecommendations else { return } + + isRefreshingRecommendations = true + defer { isRefreshingRecommendations = false } + + do { + localResponsesByRecommendationID = [:] + try await homeSummaryStore.refreshRecommendations(using: authManager, for: currentDate) + } catch { + refreshErrorMessage = error.localizedDescription + } + } + #endif + private static let headerDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "EEE, MMM d" diff --git a/SleepFocus/Services/ChatAPIService.swift b/SleepFocus/Services/ChatAPIService.swift index 8edb5de..47d2744 100644 --- a/SleepFocus/Services/ChatAPIService.swift +++ b/SleepFocus/Services/ChatAPIService.swift @@ -5,6 +5,11 @@ final class ChatAPIService { private init() {} + enum ThreadRecoveryAction { + case keepCurrentThread + case retryWithFreshThread + } + func createThread( accessToken: String, title: String?, @@ -77,12 +82,65 @@ final class ChatAPIService { return response.data.assistantMessage.content.trimmingCharacters(in: .whitespacesAndNewlines) } + func threadRecoveryAction( + for error: Error, + hadExistingThread: Bool + ) -> ThreadRecoveryAction { + guard hadExistingThread else { + return .keepCurrentThread + } + + if isRecoverableThreadError(error) { + return .retryWithFreshThread + } + + return .keepCurrentThread + } + private func shouldFallbackToLegacyRoute(_ error: Error) -> Bool { guard case APIError.requestFailed(let statusCode, _, _) = error else { return false } return statusCode == 404 } + + private func isRecoverableThreadError(_ error: Error) -> Bool { + if let apiError = error as? APIError { + switch apiError { + case .requestFailed(let statusCode, _, let message): + if statusCode == 404 { + return true + } + + if statusCode >= 500 { + return true + } + + if let message, message.localizedCaseInsensitiveContains("thread not found") { + return true + } + + return false + case .decodingFailed: + return true + case .invalidResponse: + return true + case .invalidBaseURL, .invalidPath, .encodingFailed: + return false + } + } + + if let urlError = error as? URLError { + switch urlError.code { + case .timedOut, .cannotConnectToHost, .networkConnectionLost, .notConnectedToInternet, .cannotFindHost: + return true + default: + return false + } + } + + return false + } } private struct CreateChatThreadRequest: Encodable { diff --git a/SleepFocus/Services/DeveloperAPIService.swift b/SleepFocus/Services/DeveloperAPIService.swift new file mode 100644 index 0000000..19ecb25 --- /dev/null +++ b/SleepFocus/Services/DeveloperAPIService.swift @@ -0,0 +1,46 @@ +import Foundation + +enum DeveloperAPIService { + static func clearSleepScoreArtifacts(authManager: AuthenticationManager) async throws { + let accessToken = try await authManager.validAccessToken() + var headers = ["Authorization": "Bearer \(accessToken)"] + if let userID = authManager.userID?.trimmingCharacters(in: .whitespacesAndNewlines), + !userID.isEmpty { + headers["X-User-Id"] = userID + } + + do { + let _: DeveloperClearSleepScoreResponse = try await APIWrapper.shared.post( + "v2/dev/clear-sleep-score", + body: DeveloperClearSleepScoreRequest(), + headers: headers, + responseType: DeveloperClearSleepScoreResponse.self + ) + } catch { + guard shouldFallbackToLegacyRoute(error) else { + throw error + } + + let _: DeveloperClearSleepScoreResponse = try await APIWrapper.shared.post( + "dev/clear-sleep-score", + body: DeveloperClearSleepScoreRequest(), + headers: headers, + responseType: DeveloperClearSleepScoreResponse.self + ) + } + } + + private static func shouldFallbackToLegacyRoute(_ error: Error) -> Bool { + guard case APIError.requestFailed(let statusCode, _, _) = error else { + return false + } + + return statusCode == 404 + } +} + +private struct DeveloperClearSleepScoreRequest: Encodable {} + +private struct DeveloperClearSleepScoreResponse: Decodable { + let status: String +} diff --git a/SleepFocus/Services/DeveloperStorageTools.swift b/SleepFocus/Services/DeveloperStorageTools.swift index 6a63c37..fa293e1 100644 --- a/SleepFocus/Services/DeveloperStorageTools.swift +++ b/SleepFocus/Services/DeveloperStorageTools.swift @@ -1,6 +1,18 @@ import Foundation enum DeveloperStorageTools { + static func clearHomeSummaryCache(defaults: UserDefaults = .standard) { + let keysToClear = defaults.dictionaryRepresentation().keys.filter { key in + key.hasPrefix("homeSummary.") + } + + for key in keysToClear { + defaults.removeObject(forKey: key) + } + + URLCache.shared.removeAllCachedResponses() + } + static func clearScoreFetchStorage(defaults: UserDefaults = .standard) { let keysToClear = defaults.dictionaryRepresentation().keys.filter { key in key.hasPrefix("healthSync.") diff --git a/SleepFocus/Services/HealthAutoSyncService.swift b/SleepFocus/Services/HealthAutoSyncService.swift index 03753f3..faec4e8 100644 --- a/SleepFocus/Services/HealthAutoSyncService.swift +++ b/SleepFocus/Services/HealthAutoSyncService.swift @@ -147,7 +147,7 @@ actor HealthAutoSyncService { to endDate: Date, saveState: Bool ) async throws -> HealthSyncRunSummary { - let buildResult = try await repository.buildChunkedPayloads( + let buildResult = try await buildSequentialNightPayloads( from: startDate, to: endDate, maxPayloadBytes: maxPayloadBytes @@ -156,13 +156,16 @@ actor HealthAutoSyncService { var chunksSent = 0 var bytesSent = 0 var failedChunks = 0 - let skippedChunks = buildResult.skipped.count + var skippedChunks = buildResult.skipped.count var lastSuccessfulRangeEnd: Date? - // Prioritize the most recent data first so today's score appears quickly - // when opening the app, even during large historical catch-up syncs. - let chunksByRecency = buildResult.chunks.sorted { $0.endDate > $1.endDate } - for chunk in chunksByRecency { + for chunk in buildResult.chunks { + guard !chunk.payload.streams.sleep.isEmpty else { + skippedChunks += 1 + lastSuccessfulRangeEnd = max(lastSuccessfulRangeEnd ?? chunk.endDate, chunk.endDate) + continue + } + do { let seededPayload = chunk.payload.withIdempotencyKey( deterministicIdempotencyKey( @@ -209,6 +212,59 @@ actor HealthAutoSyncService { return summary } + private func buildSequentialNightPayloads( + from startDate: Date, + to endDate: Date, + maxPayloadBytes: Int + ) async throws -> UploadChunkBuildResult { + guard startDate < endDate else { + return UploadChunkBuildResult(chunks: [], skipped: []) + } + + let calendar = Calendar.current + var chunks: [UploadChunk] = [] + var skipped: [SkippedUploadChunk] = [] + var dayStart = calendar.startOfDay(for: startDate) + let finalDayStart = calendar.startOfDay(for: endDate) + + while dayStart <= finalDayStart { + let nightWindow = selectedNightWindow(for: dayStart) + let windowStart = max(nightWindow.start, startDate) + let windowEnd = min(nightWindow.end, endDate) + + if windowStart < windowEnd { + let payload = try await repository.fetchAll(from: windowStart, to: windowEnd) + let encoded = try JSONEncoder().encode(payload) + + if encoded.count <= maxPayloadBytes { + chunks.append( + UploadChunk( + payload: payload, + byteCount: encoded.count, + startDate: windowStart, + endDate: windowEnd + ) + ) + } else { + let fallback = try await repository.buildChunkedPayloads( + from: windowStart, + to: windowEnd, + maxPayloadBytes: maxPayloadBytes + ) + chunks.append(contentsOf: fallback.chunks) + skipped.append(contentsOf: fallback.skipped) + } + } + + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: dayStart) else { + break + } + dayStart = nextDay + } + + return UploadChunkBuildResult(chunks: chunks, skipped: skipped) + } + private func uploadSinglePayloadRange( userID: String, tokenProvider: @escaping () async throws -> String, diff --git a/SleepFocus/Services/HomeSummaryStore.swift b/SleepFocus/Services/HomeSummaryStore.swift index 739f766..f02fe88 100644 --- a/SleepFocus/Services/HomeSummaryStore.swift +++ b/SleepFocus/Services/HomeSummaryStore.swift @@ -163,13 +163,18 @@ final class HomeSummaryStore: ObservableObject { } if let currentSummary = resolvedSummary, - shouldPollForModelOutputs(currentSummary, forceRecommendation: isRecommendationRefreshInFlight) { + shouldPollForModelOutputs( + currentSummary, + forceScore: onDemandTriggerResult.attemptedScore, + forceRecommendation: isRecommendationRefreshInFlight + ) { isWaitingForModel = true let polledSummary = await pollForModelSummary( using: authManager, for: selectedDate, initialSummary: currentSummary, loadToken: loadToken, + forceScore: onDemandTriggerResult.attemptedScore, forceRecommendation: isRecommendationRefreshInFlight ) guard isActiveLoad(loadToken) else { return } @@ -219,6 +224,54 @@ final class HomeSummaryStore: ObservableObject { } } + func refreshRecommendations(using authManager: AuthenticationManager, for selectedDate: Date) async throws { + let loadToken = UUID() + activeLoadToken = loadToken + let selectedDayKey = Self.apiDateFormatter.string(from: selectedDate) + let selectedMonthKey = Self.apiMonthFormatter.string(from: selectedDate) + + isRecommendationRefreshInFlight = true + isWaitingForModel = true + lastErrorMessage = nil + invalidateMonthCache(monthKey: selectedMonthKey) + + defer { + if isActiveLoad(loadToken) { + isRecommendationRefreshInFlight = false + isWaitingForModel = false + } + } + + let accessToken = try await authManager.validAccessToken() + let headers = ["Authorization": "Bearer \(accessToken)"] + let payload = PipelineRunRequest( + dayUtc: selectedDayKey, + triggerSource: "dev_recommendation_refresh", + tasks: ["recommendation"] + ) + + _ = try await postPipelineRun(payload, headers: headers) + guard isActiveLoad(loadToken) else { return } + + guard let refreshedSummary = await fetchSummary(using: authManager, for: selectedDate) else { + throw APIError.invalidResponse + } + + cacheSummary(refreshedSummary, fallbackDate: selectedDate) + summary = refreshedSummary + + let polledSummary = await pollForModelSummary( + using: authManager, + for: selectedDate, + initialSummary: refreshedSummary, + loadToken: loadToken, + forceRecommendation: true + ) + guard isActiveLoad(loadToken) else { return } + summary = polledSummary + cacheSummary(polledSummary, fallbackDate: selectedDate) + } + private func fetchSummary(using authManager: AuthenticationManager, for selectedDate: Date) async -> HomeSummary? { do { let accessToken = try await authManager.validAccessToken() @@ -356,9 +409,10 @@ final class HomeSummaryStore: ObservableObject { // Ramp up polling to improve fetching performance without spamming backend private func pollingIntervalNanos(for attempt: Int) -> UInt64 { switch attempt { - case 0: return 1_000_000_000 // 1 s - case 1: return 2_000_000_000 // 2 s - case 2: return 3_000_000_000 // 3 s + case 0: return 0 // immediate + case 1: return 1_000_000_000 // 1 s + case 2: return 2_000_000_000 // 2 s + case 3: return 3_000_000_000 // 3 s default: return 5_000_000_000 // 5 s } } @@ -368,22 +422,31 @@ final class HomeSummaryStore: ObservableObject { for selectedDate: Date, initialSummary: HomeSummary, loadToken: UUID, + forceScore: Bool = false, forceRecommendation: Bool ) async -> HomeSummary { var latest = initialSummary + var shouldForceScorePolling = forceScore var shouldForceRecommendationPolling = forceRecommendation + let pollStartedAt = Date() + var fetchAttempts = 0 for attempt in 0.. 0 { + try? await Task.sleep(nanoseconds: delay) + } guard isActiveLoad(loadToken) else { return latest } + fetchAttempts += 1 guard let next = await fetchSummary(using: authManager, for: selectedDate) else { continue } @@ -395,25 +458,47 @@ final class HomeSummaryStore: ObservableObject { summary = latest cacheSummary(latest, fallbackDate: selectedDate) } + if latest.sleepQuality.score != nil || isTerminalScoreState(latest) { + shouldForceScorePolling = false + } if hasReadyRecommendations(latest) { shouldForceRecommendationPolling = false isRecommendationRefreshInFlight = false + let elapsed = Date().timeIntervalSince(pollStartedAt) + print("[HomeSummaryStore] recommendations ready after \(fetchAttempts) poll(s), \(String(format: "%.2f", elapsed))s") } - if !shouldPollForModelOutputs(latest, forceRecommendation: shouldForceRecommendationPolling) { + if !shouldPollForModelOutputs( + latest, + forceScore: shouldForceScorePolling, + forceRecommendation: shouldForceRecommendationPolling + ) { return latest } } + let elapsed = Date().timeIntervalSince(pollStartedAt) + print("[HomeSummaryStore] model polling ended after \(fetchAttempts) poll(s), \(String(format: "%.2f", elapsed))s") return latest } - private func shouldPollForModelOutputs(_ summary: HomeSummary, forceRecommendation: Bool = false) -> Bool { - shouldPollForScoreOutput(summary) || shouldPollForRecommendationOutput(summary, forceRecommendation: forceRecommendation) + private func shouldPollForModelOutputs( + _ summary: HomeSummary, + forceScore: Bool = false, + forceRecommendation: Bool = false + ) -> Bool { + shouldPollForScoreOutput(summary, forceScore: forceScore) + || shouldPollForRecommendationOutput(summary, forceRecommendation: forceRecommendation) } - private func shouldPollForScoreOutput(_ summary: HomeSummary) -> Bool { - !isModelBacked(summary) && isActiveProcessing(summary.processing) + private func shouldPollForScoreOutput(_ summary: HomeSummary, forceScore: Bool = false) -> Bool { + guard summary.sleepQuality.score == nil else { + return false + } + if forceScore && !isTerminalScoreState(summary) { + return true + } + return !isModelBacked(summary) && isActiveProcessing(summary.processing) } private func shouldPollForRecommendationOutput(_ summary: HomeSummary, forceRecommendation: Bool = false) -> Bool { @@ -430,6 +515,11 @@ final class HomeSummaryStore: ObservableObject { return sleepSource.contains("model-v3") || focusSource.contains("model-v3") || !summary.placeholder } + private func isTerminalScoreState(_ summary: HomeSummary) -> Bool { + let source = summary.sleepQuality.source?.lowercased() ?? "" + return source == "failed" || source == "no_healthkit_sleep" + } + private func hasReadyRecommendations(_ summary: HomeSummary) -> Bool { if let details = summary.recommendationDetails, !details.isEmpty { return true @@ -449,18 +539,22 @@ final class HomeSummaryStore: ObservableObject { } private func isRecommendationStale(_ summary: HomeSummary, selectedDate: Date) -> Bool { - let calendar = Calendar.current - let selectedStart = calendar.startOfDay(for: selectedDate) - let todayStart = calendar.startOfDay(for: Date()) - guard selectedStart <= todayStart else { + guard isSelectedDateTodayOrEarlier(selectedDate) else { return false } - let status = summary.recommendationStatus?.lowercased() ?? "" - if !hasReadyRecommendations(summary) || status == "loading" || status == "unavailable" { + if !hasReadyRecommendations(summary) { return true } + guard isSelectedDateToday(selectedDate) else { + return false + } + + guard !isActiveProcessing(summary.processing) else { + return false + } + guard let generatedAt = summary.recommendationContext?.generatedAt, let generatedDate = parseProcessingTimestamp(generatedAt) else { return true @@ -469,6 +563,18 @@ final class HomeSummaryStore: ObservableObject { return Date().timeIntervalSince(generatedDate) > recommendationFreshnessWindow } + private func isSelectedDateToday(_ selectedDate: Date) -> Bool { + let calendar = Calendar.current + return calendar.isDate(selectedDate, inSameDayAs: Date()) + } + + private func isSelectedDateTodayOrEarlier(_ selectedDate: Date) -> Bool { + let calendar = Calendar.current + let selectedStart = calendar.startOfDay(for: selectedDate) + let todayStart = calendar.startOfDay(for: Date()) + return selectedStart <= todayStart + } + private func isActiveProcessing(_ processing: ProcessingSummary?) -> Bool { guard let processing else { return false } @@ -549,6 +655,10 @@ final class HomeSummaryStore: ObservableObject { } // HealthKit sleep data must be present to justify a sync attempt + if isNoHealthKitSleepSummary(summary), !hasLocalSleepData { + return false + } + return hasLocalSleepData } @@ -572,6 +682,10 @@ final class HomeSummaryStore: ObservableObject { return false } + if isNoHealthKitSleepSummary(summary), !hasLocalSleepData { + return false + } + return true } @@ -656,20 +770,17 @@ final class HomeSummaryStore: ObservableObject { guard summary.sleepQuality.score == nil else { return false } - guard hasLocalSleepData else { + guard !isNoHealthKitSleepSummary(summary) || hasLocalSleepData else { return false } - guard let thresholdDate = calendar.date(byAdding: .day, value: -historicalBackfillThresholdDays, to: todayStart) else { + guard hasLocalSleepData else { return false } - return selectedStart >= thresholdDate + return true } private func recommendationTriggerSource(for selectedDate: Date, summary: HomeSummary) -> String? { - let calendar = Calendar.current - let selectedStart = calendar.startOfDay(for: selectedDate) - let todayStart = calendar.startOfDay(for: Date()) - guard selectedStart <= todayStart else { + guard isSelectedDateTodayOrEarlier(selectedDate) else { return nil } @@ -678,25 +789,31 @@ final class HomeSummaryStore: ObservableObject { } let status = summary.recommendationStatus?.lowercased() ?? "" - if status == "loading" { + if status == "loading" && !hasReadyRecommendations(summary) { return isRecommendationActivelyLoading(summary) ? nil - : "home_summary_recommendation_refresh" + : "home_summary_missing_recommendation" + } + + guard hasReadyRecommendations(summary) else { + return "home_summary_missing_recommendation" } guard isRecommendationStale(summary, selectedDate: selectedDate) else { return nil } - return hasReadyRecommendations(summary) - ? "home_summary_recommendation_refresh" - : "home_summary_missing_recommendation" + return "home_summary_recommendation_refresh" } private func isRecommendationPrerequisiteReady(_ summary: HomeSummary) -> Bool { summary.sleepQuality.score != nil } + private func isNoHealthKitSleepSummary(_ summary: HomeSummary?) -> Bool { + summary?.sleepQuality.source?.lowercased() == "no_healthkit_sleep" + } + private func triggerRecommendationPipelineIfNeeded( using authManager: AuthenticationManager, for selectedDate: Date, @@ -814,6 +931,12 @@ final class HomeSummaryStore: ObservableObject { let recommendationId = detail.recommendationId.trimmingCharacters(in: .whitespacesAndNewlines) let action = detail.action.trimmingCharacters(in: .whitespacesAndNewlines) let reason = detail.reason.trimmingCharacters(in: .whitespacesAndNewlines) + let icon = detail.icon?.trimmingCharacters(in: .whitespacesAndNewlines) + let tags = detail.tags? + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(3) + .map { $0 } guard !recommendationId.isEmpty, !action.isEmpty else { return nil } @@ -850,6 +973,8 @@ final class HomeSummaryStore: ObservableObject { recommendationId: recommendationId, action: action, reason: reason, + icon: (icon?.isEmpty ?? true) ? nil : icon, + tags: (tags?.isEmpty ?? true) ? nil : tags, multiQuestion: multiQuestion, response: response ) diff --git a/SleepFocus/Services/PhoneSessionReceiver.swift b/SleepFocus/Services/PhoneSessionReceiver.swift new file mode 100644 index 0000000..f714072 --- /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, .backgroundPermissionDenied]) + + 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..c1b4d78 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,6 +101,7 @@ 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 var wakeWindowOverridesByDay: [String: WakeWindowScheduleOverride] private var smartAlarmBedtimeAlarmIsManual: Bool @@ -138,6 +142,12 @@ 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 + } defaults.set(self.earliestWakeupMinutes, forKey: Keys.smartAlarmEarliestWakeupMinutes) defaults.set(self.finalWakeupMinutes, forKey: Keys.smartAlarmFinalWakeupMinutes) @@ -190,13 +200,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 +235,8 @@ final class SleepPreferencesStore: ObservableObject { return "Alarm time" case .bedtime: return "Bedtime" + case .automatic: + return "Preferred bedtime" } } @@ -219,6 +246,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 +255,24 @@ 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 .backgroundPermissionDenied: + return "Open the watch app and enable background smart alarm permission." + case .none: + return "The watch will monitor around your preferred bedtime and pick the wake time for you." + } + } if hasFreshSmartAlarmPayload, hasMatchingProjectionInput, @@ -250,9 +297,71 @@ 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 .backgroundPermissionDenied: + return "Open the watch app and re-enable background smart alarm permission." + 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))" } @@ -406,7 +515,13 @@ 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? { @@ -434,6 +549,11 @@ final class SleepPreferencesStore: ObservableObject { force: Bool = false, timezone: TimeZone = .current ) async { + guard smartAlarmMode != .automatic else { + smartAlarmLastErrorMessage = nil + return + } + if isRefreshingSmartAlarmGuidance { return } @@ -521,6 +641,47 @@ final class SleepPreferencesStore: ObservableObject { } } + 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,6 +700,8 @@ final class SleepPreferencesStore: ObservableObject { return smartAlarmWakeTimeMinutes case .bedtime: return smartAlarmBedtimeMinutes + case .automatic: + return preferredBedtimeMinutes } } @@ -561,6 +724,8 @@ final class SleepPreferencesStore: ObservableObject { return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) case .bedtime: return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) + case .automatic: + return Self.normalizeMinutes(smartAlarmWakeTimeMinutes) } } @@ -812,6 +977,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) { @@ -870,6 +1057,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,6 +1097,7 @@ 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" 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..f05476a --- /dev/null +++ b/SleepFocusWatch Watch App/AutoSmartAlarmModels.swift @@ -0,0 +1,44 @@ +import Foundation + +enum AutoSmartAlarmStatus: String, Codable { + case scheduled + case noFit + case timeout + case unsupported + case permissionDenied + case backgroundPermissionDenied +} + +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..eae0d70 --- /dev/null +++ b/SleepFocusWatch Watch App/AutoSmartAlarmSessionManager.swift @@ -0,0 +1,567 @@ +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 + + var shouldScheduleFallbackWake = false + if currentSessionKind == .monitoring { + detector.stop() + if !didCompleteMonitoringFlow, + reason == .expired || reason == .suppressedBySystem { + emitDecision(status: .timeout, onset: nil, wake: nil) + shouldScheduleFallbackWake = true + } + } else if currentSessionKind == .wake, + reason == .error, + Self.isBackgroundSchedulingPermissionError(error) { + emitDecision(status: .backgroundPermissionDenied, onset: nil, wake: nil) + statusText = "Background permission needed" + detailText = "Open Settings on Apple Watch and allow SleepFocus background refresh." + } + + currentSession = nil + persistSessionKind(nil) + persistMonitoringStart(nil) + currentSessionKind = nil + + if shouldScheduleFallbackWake { + scheduleFallbackWakeIfPossible() + } + + refreshPublishedState() + } + + private func armMonitoringIfNeeded(reschedule: Bool, immediateStart: Bool = false) async { + guard let config = currentConfig, config.isAutomaticModeEnabled else { + return + } + + guard isAppActive else { + statusText = "Open watch app" + detailText = "Open SleepFocus on your watch to arm the smart alarm." + 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 monitoringStart = nextMonitoringStartDate(for: config) + guard immediateStart || monitoringStart <= Date() else { + scheduleFallbackWakeIfPossible(for: config) + return + } + + startMonitoringSession() + } + + private func startMonitoringSession() { + let monitoringSession = WKExtendedRuntimeSession() + monitoringSession.delegate = self + currentSession = monitoringSession + currentSessionKind = .monitoring + didCompleteMonitoringFlow = false + persistSessionKind(.monitoring) + + monitoringSession.start() + statusText = "Monitoring now" + detailText = "Listening for sleep onset on your watch." + 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) + stopAllSessions(clearWake: false) + scheduleFallbackWakeIfPossible(for: config) + 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) + stopAllSessions(clearWake: false) + scheduleWakeSession(for: wakeDate) + } + + private func handleMonitoringTimeout() { + guard !didCompleteMonitoringFlow else { + return + } + + didCompleteMonitoringFlow = true + detector.stop() + emitDecision(status: .timeout, onset: nil, wake: nil) + stopAllSessions(clearWake: false) + scheduleFallbackWakeIfPossible() + statusText = "Monitoring timed out" + detailText = "Your current alarm remains unchanged." + } + + private func scheduleFallbackWakeIfPossible(for config: AutoSmartAlarmConfig? = nil) { + guard let config = config ?? currentConfig, + config.isAutomaticModeEnabled, + let fallbackWakeDate = Self.selectFallbackWakeDate(for: config) else { + return + } + + scheduleWakeSession(for: fallbackWakeDate) + statusText = "Wake fallback armed" + detailText = "Open the watch app around \(Self.timeFormatter.string(from: nextMonitoringStartDate(for: config))) for sleep detection." + } + + private func scheduleWakeSession(for wakeDate: Date) { + guard isAppActive else { + statusText = "Open watch app" + detailText = "Open SleepFocus on your watch to arm the wake haptic." + wakeTimeDisplay = "--" + return + } + + 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() + let session = currentSession + currentSession = nil + currentSessionKind = nil + persistSessionKind(nil) + persistMonitoringStart(nil) + session?.invalidate() + 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 var isAppActive: Bool { + WKApplication.shared().applicationState == .active + } + + 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 + } + + static func selectFallbackWakeDate( + for config: AutoSmartAlarmConfig, + reference: Date = Date(), + calendar inputCalendar: Calendar = .current + ) -> Date? { + var calendar = inputCalendar + if let timezone = TimeZone(identifier: config.timezoneIdentifier) { + calendar.timeZone = timezone + } + + let fallbackMinutes = normalizeMinutes(config.fallbackWakeTimeMinutes) + let startOfToday = calendar.startOfDay(for: reference) + let todayCandidate = calendar.date(byAdding: .minute, value: fallbackMinutes, to: startOfToday) + if let todayCandidate, todayCandidate > reference { + return todayCandidate + } + + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { + return nil + } + return calendar.date(byAdding: .minute, value: fallbackMinutes, to: tomorrow) + } + + private static func isBackgroundSchedulingPermissionError(_ error: Error?) -> Bool { + guard let error = error as NSError?, + error.domain == WKExtendedRuntimeSessionErrorDomain else { + return false + } + + return error.code == Constants.backgroundAppRefreshDisabledErrorCode || + error.code == Constants.notApprovedToScheduleErrorCode + } + + 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 + static let backgroundAppRefreshDisabledErrorCode = 6 + static let notApprovedToScheduleErrorCode = 8 + } + + 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..3dcfb29 --- /dev/null +++ b/SleepFocusWatch Watch AppTests/SleepFocusWatch_Watch_AppTests.swift @@ -0,0 +1,114 @@ +// +// 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))) + } + + @Test func selectsTodayFallbackWakeWhenStillUpcoming() 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 reference = calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 1, minute: 30))! + + let wakeDate = AutoSmartAlarmSessionManager.selectFallbackWakeDate(for: config, reference: reference, calendar: calendar) + + #expect(wakeDate == calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 7))) + } + + @Test func selectsTomorrowFallbackWakeWhenTodayHasPassed() 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 reference = calendar.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 7, minute: 1))! + + let wakeDate = AutoSmartAlarmSessionManager.selectFallbackWakeDate(for: config, reference: reference, calendar: calendar) + + #expect(wakeDate == calendar.date(from: DateComponents(year: 2026, month: 4, day: 6, hour: 7))) + } +} 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" + } } }