diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 9a11022d6..af35f685c 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -24,6 +24,18 @@ 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77B22F5BE1AC00E96858 /* PreferredGlucoseUnit.swift */; }; 379BECAA2F6588300069DC62 /* RestartLiveActivityIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */; }; 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */; }; + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */; }; + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */; }; + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DDB2F60A2000004BD8B /* WatchFormat.swift */; }; + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */; }; + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */; }; + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */; }; 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -428,6 +440,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 37989DB42F609B0C0004BD8B; + remoteInfo = "LoopFollowWatch Watch App"; + }; 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -445,6 +464,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 37989DC32F609B0E0004BD8B /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 37989DBF2F609B0E0004BD8B /* LoopFollowWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -475,6 +505,13 @@ 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowLAExtensionExtension.entitlements; sourceTree = ""; }; 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartLiveActivityIntent.swift; sourceTree = ""; }; 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "LoopFollowWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchComplicationProvider.swift; sourceTree = ""; }; + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationEntryBuilder.swift; sourceTree = ""; }; + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFormat.swift; sourceTree = ""; }; + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX26.2.sdk/System/iOSSupport/System/Library/Frameworks/WatchConnectivity.framework; sourceTree = DEVELOPER_DIR; }; + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionReceiver.swift; sourceTree = ""; }; 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowLAExtensionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; @@ -884,6 +921,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "LoopFollowWatch Watch App"; sourceTree = ""; }; 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowLAExtension; sourceTree = ""; }; 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; @@ -891,6 +929,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 37989DB22F609B0C0004BD8B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD62F5B6B4A00EEB289 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -913,6 +958,8 @@ files = ( FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, + DD48781C2C7DAF140048F05C /* SwiftJWT in Frameworks */, + 37989DD22F60A0ED0004BD8B /* WatchConnectivity.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -934,10 +981,22 @@ 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, + 37989DD32F60A11E0004BD8B /* WatchConnectivityManager.swift */, ); path = LiveActivity; sourceTree = ""; }; + 37989DCB2F609D9C0004BD8B /* WatchComplication */ = { + isa = PBXGroup; + children = ( + 37989DD52F60A15E0004BD8B /* WatchSessionReceiver.swift */, + 37989DCF2F609F570004BD8B /* ComplicationEntryBuilder.swift */, + 37989DCC2F609DAE0004BD8B /* WatchComplicationProvider.swift */, + 37989DDB2F60A2000004BD8B /* WatchFormat.swift */, + ); + path = WatchComplication; + sourceTree = ""; + }; 6589CC552E9E7D1600BB18FE /* ImportExport */ = { isa = PBXGroup; children = ( @@ -974,6 +1033,7 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */ = { isa = PBXGroup; children = ( + 37989DD12F60A0ED0004BD8B /* WatchConnectivity.framework */, DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */, FCFEEC9D2486E68E00402A7F /* WebKit.framework */, A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */, @@ -1588,6 +1648,7 @@ isa = PBXGroup; children = ( 379BECAF2F65DA4B0069DC62 /* LiveActivitySettingsView.swift */, + 37989DCB2F609D9C0004BD8B /* WatchComplication */, 376310762F5CD65100656488 /* LiveActivity */, 6589CC612E9E7D1600BB18FE /* Settings */, 65AC26702ED245DF00421360 /* Treatments */, @@ -1630,6 +1691,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1642,6 +1704,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */, ); name = Products; sourceTree = ""; @@ -1714,6 +1777,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */; + buildPhases = ( + 37989DB12F609B0C0004BD8B /* Sources */, + 37989DB22F609B0C0004BD8B /* Frameworks */, + 37989DB32F609B0C0004BD8B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 37989DB62F609B0C0004BD8B /* LoopFollowWatch Watch App */, + ); + name = "LoopFollowWatch Watch App"; + packageProductDependencies = ( + ); + productName = "LoopFollowWatch Watch App"; + productReference = 37989DB52F609B0C0004BD8B /* LoopFollowWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */; @@ -1771,11 +1856,13 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + 37989DC32F609B0E0004BD8B /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1794,10 +1881,13 @@ FC97880C2485969B00A7906C /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 1140; ORGANIZATIONNAME = "Jon Fawcett"; TargetAttributes = { + 37989DB42F609B0C0004BD8B = { + CreatedOnToolsVersion = 26.3; + }; 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; @@ -1828,11 +1918,19 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 37989DB32F609B0C0004BD8B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD72F5B6B4A00EEB289 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2063,6 +2161,22 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 37989DB12F609B0C0004BD8B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 37989DC62F609C5E0004BD8B /* GlucoseSnapshotStore.swift in Sources */, + 37989DD02F609F570004BD8B /* ComplicationEntryBuilder.swift in Sources */, + 37989DC52F609C550004BD8B /* GlucoseSnapshot.swift in Sources */, + 37989DC72F609C730004BD8B /* AppGroupID.swift in Sources */, + 37989DD62F60A15E0004BD8B /* WatchSessionReceiver.swift in Sources */, + 37989DC82F609C7A0004BD8B /* LAAppGroupSettings.swift in Sources */, + 37989DCE2F609ED40004BD8B /* WatchComplicationProvider.swift in Sources */, + 37989DDC2F60A2010004BD8B /* WatchFormat.swift in Sources */, + 37989DDD2F60A2020004BD8B /* GlucoseConversion.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 37A4BDD52F5B6B4A00EEB289 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2229,6 +2343,7 @@ FC8589BF252B54F500C8FC73 /* Mobileprovision.swift in Sources */, DD4878052C7B2C970048F05C /* Storage.swift in Sources */, DD493AE12ACF22FE009A6922 /* Profile.swift in Sources */, + 37989DD42F60A11E0004BD8B /* WatchConnectivityManager.swift in Sources */, 6589CC622E9E7D1600BB18FE /* ImportExportSettingsView.swift in Sources */, 6589CC632E9E7D1600BB18FE /* GeneralSettingsView.swift in Sources */, 5544D8C363FB5D3B9BF8CE4A /* APNSettingsView.swift in Sources */, @@ -2377,6 +2492,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 37989DBE2F609B0E0004BD8B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 37989DB42F609B0C0004BD8B /* LoopFollowWatch Watch App */; + targetProxy = 37989DBD2F609B0E0004BD8B /* PBXContainerItemProxy */; + }; 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -2410,6 +2530,91 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 37989DC12F609B0E0004BD8B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 9.6; + }; + name = Debug; + }; + 37989DC22F609B0E0004BD8B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = LoopFollow; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = "LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWatch; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_MODULE_NAME).WatchComplicationProvider"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "com.$(unique_id).LoopFollow$(app_suffix)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).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 = 9.6; + }; + name = Release; + }; 37A4BDEB2F5B6B4C00EEB289 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2751,6 +2956,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 37989DC02F609B0E0004BD8B /* Build configuration list for PBXNativeTarget "LoopFollowWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 37989DC12F609B0E0004BD8B /* Debug */, + 37989DC22F609B0E0004BD8B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 37A4BDEA2F5B6B4C00EEB289 /* Build configuration list for PBXNativeTarget "LoopFollowLAExtensionExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index a6fd9f2b9..e22822545 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -41,6 +41,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Ensure VolumeButtonHandler is initialized so it can receive alarm notifications _ = VolumeButtonHandler.shared + WatchConnectivityManager.shared.activate() + // Register for remote notifications DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() @@ -48,12 +50,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundRefreshManager.shared.register() - // Detect Before-First-Unlock launch. If protected data is unavailable here, - // StorageValues were cached from encrypted UserDefaults and need a reload - // on the first foreground after the user unlocks. - let bfu = !UIApplication.shared.isProtectedDataAvailable + // Detect Before-First-Unlock launch. isProtectedDataAvailable returns false + // for ANY locked-screen background launch, not only post-reboot. Standard + // UserDefaults use NSFileProtectionCompleteUntilFirstUserAuthentication — + // they stay readable after the first unlock even when the screen is locked. + // True BFU (boot before first unlock) is the only case where UserDefaults + // is actually inaccessible; in that state every StorageValue reads as its + // default — including migrationStep, which is always ≥ 1 for existing users. + // Guard against false positives by checking that migrationStep is still 0 + // (its default), meaning the real value couldn't be read from disk. + let protectedDataUnavailable = !UIApplication.shared.isProtectedDataAvailable + let bfu = protectedDataUnavailable && Storage.shared.migrationStep.value == 0 Storage.shared.needsBFUReload = bfu - LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!protectedDataUnavailable), migrationStep=\(Storage.shared.migrationStep.value), needsBFUReload=\(bfu)") return true } diff --git a/LoopFollow/Contact/ContactImageUpdater.swift b/LoopFollow/Contact/ContactImageUpdater.swift index 939da4500..fd02575e1 100644 --- a/LoopFollow/Contact/ContactImageUpdater.swift +++ b/LoopFollow/Contact/ContactImageUpdater.swift @@ -119,15 +119,18 @@ class ContactImageUpdater { private func getIncludedFields(for contactType: ContactType) -> [ContactType] { var included: [ContactType] = [] if Storage.shared.contactTrend.value == .include, - Storage.shared.contactTrendTarget.value == contactType { + Storage.shared.contactTrendTarget.value == contactType + { included.append(.Trend) } if Storage.shared.contactDelta.value == .include, - Storage.shared.contactDeltaTarget.value == contactType { + Storage.shared.contactDeltaTarget.value == contactType + { included.append(.Delta) } if Storage.shared.contactIOB.value == .include, - Storage.shared.contactIOBTarget.value == contactType { + Storage.shared.contactIOBTarget.value == contactType + { included.append(.IOB) } return included diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index ad1fb9ff1..12b9568cd 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -236,6 +236,7 @@ extension MainViewController { let latestBG = entries[latestEntryIndex].sgv let priorBG = entries[latestEntryIndex - 1].sgv let deltaBG = latestBG - priorBG + let lastBGTime = entries[latestEntryIndex].date self.updateServerText(with: sourceName) @@ -267,6 +268,7 @@ extension MainViewController { // Live Activity storage Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime + Storage.shared.lastBgMgdl.value = Double(latestBG) Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 5eb1187b8..2ce09159c 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -58,6 +58,7 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 7951e122a..4e645b6b2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -2,6 +2,9 @@ // GlucoseSnapshotStore.swift import Foundation +import os.log + +private let storeLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow", category: "GlucoseSnapshotStore") /// Persists the latest GlucoseSnapshot into the App Group container so that: /// - the Live Activity extension can read it @@ -17,31 +20,39 @@ final class GlucoseSnapshotStore { // MARK: - Public API - func save(_ snapshot: GlucoseSnapshot) { + func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) { queue.async { do { let url = try self.fileURL() + // GlucoseSnapshot writes `updatedAt` as a Double via its custom + // encoder, so no JSONEncoder date strategy is required. let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) + os_log("GlucoseSnapshotStore: saved snapshot g=%d to %{public}@", log: storeLog, type: .debug, Int(snapshot.glucose), url.lastPathComponent) } catch { - // Intentionally silent (extension-safe, no dependencies). + os_log("GlucoseSnapshotStore: save failed — %{public}@", log: storeLog, type: .error, error.localizedDescription) } + completion?() } } func load() -> GlucoseSnapshot? { do { let url = try fileURL() - guard FileManager.default.fileExists(atPath: url.path) else { return nil } + guard FileManager.default.fileExists(atPath: url.path) else { + os_log("GlucoseSnapshotStore: file not found at %{public}@", log: storeLog, type: .debug, url.lastPathComponent) + return nil + } let data = try Data(contentsOf: url) + // GlucoseSnapshot reads `updatedAt` as a Double via its custom decoder, + // so no JSONDecoder date strategy is required. let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(GlucoseSnapshot.self, from: data) + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + return snapshot } catch { - // Intentionally silent (extension-safe, no dependencies). + os_log("GlucoseSnapshotStore: load failed — %{public}@", log: storeLog, type: .error, error.localizedDescription) return nil } } diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 6359fe55e..715323524 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -149,6 +149,7 @@ enum LAAppGroupSettings { static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" + static let watchSelectedSlots = "watch.selectedSlots" } private static var defaults: UserDefaults? { @@ -206,6 +207,22 @@ enum LAAppGroupSettings { return LiveActivitySlotOption(rawValue: raw) ?? LiveActivitySlotDefaults.smallWidgetSlot } + // MARK: - Watch selected slots (ordered, variable-length) + + /// Persists the user's ordered list of selected Watch data slots. + static func setWatchSelectedSlots(_ slots: [LiveActivitySlotOption]) { + defaults?.set(slots.map(\.rawValue), forKey: Keys.watchSelectedSlots) + } + + /// Returns the ordered list of selected Watch data slots. + /// Falls back to a sensible default if nothing is saved. + static func watchSelectedSlots() -> [LiveActivitySlotOption] { + guard let raw = defaults?.stringArray(forKey: Keys.watchSelectedSlots) else { + return [.iob, .cob, .projectedBG, .battery] + } + return raw.compactMap { LiveActivitySlotOption(rawValue: $0) } + } + // MARK: - Display Name static func setDisplayName(_ name: String, show: Bool) { diff --git a/LoopFollow/LiveActivity/LALivenessMarker.swift b/LoopFollow/LiveActivity/LALivenessMarker.swift index 62da0a8a6..ce066e489 100644 --- a/LoopFollow/LiveActivity/LALivenessMarker.swift +++ b/LoopFollow/LiveActivity/LALivenessMarker.swift @@ -1,11 +1,5 @@ -// -// LALivenessMarker.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-04-01. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LALivenessMarker.swift import SwiftUI @@ -24,4 +18,4 @@ struct LALivenessMarker: View { private var markerID: String { "\(seq)-\(producedAt.timeIntervalSince1970)" } -} \ No newline at end of file +} diff --git a/LoopFollow/LiveActivity/LALivenessStore.swift b/LoopFollow/LiveActivity/LALivenessStore.swift index 6bb552395..e6f8ebe98 100644 --- a/LoopFollow/LiveActivity/LALivenessStore.swift +++ b/LoopFollow/LiveActivity/LALivenessStore.swift @@ -1,11 +1,5 @@ -// -// LALivenessStore.swift -// LoopFollow -// -// Created by Philippe Achkar on 2026-04-01. -// Copyright © 2026 Jon Fawcett. All rights reserved. -// - +// LoopFollow +// LALivenessStore.swift import Foundation diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 206b66182..6e7e97707 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -150,7 +150,7 @@ final class LiveActivityManager { message: "[LA] foreground restart: current=nil (old activity not bound locally), ending all existing LAs before restart" ) current = nil - + Task { for activity in Activity.activities { await activity.end(nil, dismissalPolicy: .immediate) @@ -178,7 +178,7 @@ final class LiveActivityManager { } } } - + @objc private func handleBackgroundAudioFailed() { guard Storage.shared.laEnabled.value, current != nil else { return } // The background audio session has permanently failed — the app will lose its @@ -188,7 +188,7 @@ final class LiveActivityManager { Storage.shared.laRenewBy.value = Date().timeIntervalSince1970 refreshFromCurrentState(reason: "audio-session-failed") } - + private func shouldRestartBecauseExtensionLooksStuck() -> Bool { guard Storage.shared.laEnabled.value else { return false } guard !dismissedByUser else { return false } @@ -554,7 +554,7 @@ final class LiveActivityManager { highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) - //WatchConnectivityManager.shared.send(snapshot: snapshot) + WatchConnectivityManager.shared.send(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. guard Storage.shared.laEnabled.value, !dismissedByUser else { return } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 38bde98d0..a5984ee12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -10,8 +10,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { // MARK: - Core Glucose var glucoseMgdl: Double? { - guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } - return Double(bg) + guard let bg = Storage.shared.lastBgMgdl.value, bg > 0 else { return nil } + return bg } var deltaMgdl: Double? { diff --git a/LoopFollow/LiveActivity/WatchConnectivityManager.swift b/LoopFollow/LiveActivity/WatchConnectivityManager.swift new file mode 100644 index 000000000..a3e61c16d --- /dev/null +++ b/LoopFollow/LiveActivity/WatchConnectivityManager.swift @@ -0,0 +1,157 @@ +// LoopFollow +// WatchConnectivityManager.swift + +import Foundation +import WatchConnectivity + +final class WatchConnectivityManager: NSObject { + // MARK: - Shared Instance + + static let shared = WatchConnectivityManager() + + // MARK: - Init + + /// Serial queue protecting mutable state (the last-ack timestamp) from + /// concurrent access: `send(snapshot:)` may be called from any thread, and + /// WCSession delegate callbacks arrive on an arbitrary background queue. + private let stateQueue = DispatchQueue(label: "com.loopfollow.WatchConnectivityManager.state") + + /// Backing storage for `lastWatchAckTimestamp`. Always access via the + /// thread-safe accessor below. + private var _lastWatchAckTimestamp: TimeInterval = 0 + + /// Timestamp of the last snapshot the Watch ACK'd. Read/write is serialized + /// through `stateQueue`. + private var lastWatchAckTimestamp: TimeInterval { + get { stateQueue.sync { _lastWatchAckTimestamp } } + set { stateQueue.sync { _lastWatchAckTimestamp = newValue } } + } + + override private init() { + super.init() + } + + // MARK: - Setup + + /// Call once from AppDelegate after app launch. + func activate() { + guard WCSession.isSupported() else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession not supported on this device") + return + } + WCSession.default.delegate = self + WCSession.default.activate() + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: WCSession activation requested") + } + + // MARK: - Send Snapshot + + /// Sends the latest GlucoseSnapshot to the Watch via transferUserInfo. + /// Safe to call from any thread. + /// No-ops silently if Watch is not paired or reachable. + func send(snapshot: GlucoseSnapshot) { + guard WCSession.isSupported() else { return } + + let session = WCSession.default + + guard session.activationState == .activated else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session not activated, skipping send") + return + } + + guard session.isPaired else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: no paired Watch, skipping send") + return + } + + do { + // GlucoseSnapshot has a custom encoder that writes updatedAt as a + // Double (timeIntervalSince1970), so no date strategy needs to be set. + let encoder = JSONEncoder() + let data = try encoder.encode(snapshot) + let payload: [String: Any] = ["snapshot": data] + + // Warn if Watch hasn't ACK'd this or a recent snapshot. + let behindBy = snapshot.updatedAt.timeIntervalSince1970 - lastWatchAckTimestamp + if lastWatchAckTimestamp > 0, behindBy > 600 { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK is \(Int(behindBy))s behind — Watch may be missing deliveries") + } + + // sendMessage: immediate delivery when Watch app is in foreground. + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot sent via sendMessage (reachable)") + } + + // Cancel outstanding transfers before queuing — only the latest snapshot matters. + // session.outstandingUserInfoTransfers.forEach { $0.cancel() } + + // transferUserInfo: guaranteed queued delivery for background wakes. + session.transferUserInfo(payload) + + // applicationContext: latest-state mirror for next launch / scheduled refresh. + do { + try session.updateApplicationContext(payload) + } catch { + LogManager.shared.log( + category: .watch, + message: "WatchConnectivityManager: failed to update applicationContext — \(error)" + ) + } + + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: snapshot queued via transferUserInfo") + } catch { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: failed to encode snapshot — \(error)") + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchConnectivityManager: WCSessionDelegate { + func session( + _: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation failed — \(error)") + } else { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: activation complete — state \(activationState.rawValue)") + } + } + + /// When the Watch app comes to the foreground, send the latest snapshot immediately + /// so the Watch app has fresh data without waiting for the next BG poll. + /// Receives ACKs from the Watch (sent after each snapshot is saved). + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + if let ackTimestamp = message["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK received for snapshot at \(ackTimestamp)") + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + if let ackTimestamp = userInfo["watchAck"] as? TimeInterval { + lastWatchAckTimestamp = ackTimestamp + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch ACK (userInfo) received for snapshot at \(ackTimestamp)") + } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { return } + if let snapshot = GlucoseSnapshotStore.shared.load() { + send(snapshot: snapshot) + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: Watch became reachable — snapshot pushed") + } + } + + func sessionDidBecomeInactive(_: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session became inactive") + } + + func sessionDidDeactivate(_: WCSession) { + LogManager.shared.log(category: .watch, message: "WatchConnectivityManager: session deactivated — reactivating") + WCSession.default.activate() + } +} diff --git a/LoopFollow/Log/LogManager.swift b/LoopFollow/Log/LogManager.swift index 22403cd0f..545aa586f 100644 --- a/LoopFollow/Log/LogManager.swift +++ b/LoopFollow/Log/LogManager.swift @@ -29,6 +29,7 @@ class LogManager { case calendar = "Calendar" case deviceStatus = "Device Status" case remote = "Remote" + case watch = "Watch" } init() { diff --git a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index c928251a4..500362e57 100644 --- a/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/LoopFollow/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,41 +1,46 @@ { - "images" : [ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "filename" : "1024.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "1024-dark.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" - }, + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024-dark.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "appearances" : [ { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "1024-tinted.png", - "idiom" : "universal", - "platform" : "ios", - "scale" : "1x", - "size" : "1024x1024" + "appearance" : "luminosity", + "value" : "tinted" } - ], - "info" : { - "author" : "xcode", - "version" : 1 + ], + "filename" : "1024-tinted.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 11997da35..680452ebf 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -93,6 +93,7 @@ class Storage { var lastBgReadingTimeSeconds = StorageValue(key: "lastBgReadingTimeSeconds", defaultValue: nil) var lastDeltaMgdl = StorageValue(key: "lastDeltaMgdl", defaultValue: nil) var lastTrendCode = StorageValue(key: "lastTrendCode", defaultValue: nil) + var lastBgMgdl = StorageValue(key: "lastBgMgdl", defaultValue: nil) var lastIOB = StorageValue(key: "lastIOB", defaultValue: nil) var lastCOB = StorageValue(key: "lastCOB", defaultValue: nil) var projectedBgMgdl = StorageValue(key: "projectedBgMgdl", defaultValue: nil) @@ -303,6 +304,7 @@ class Storage { lastBgReadingTimeSeconds.reload() lastDeltaMgdl.reload() lastTrendCode.reload() + lastBgMgdl.reload() lastIOB.reload() lastCOB.reload() projectedBgMgdl.reload() diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ac1f19a24..3b91affc5 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -704,8 +704,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Observable.shared.currentAlarm.value != nil, let snoozerIndex = getSnoozerTabIndex(), snoozerIndex < vcs.count { + LogManager.shared.log(category: .general, message: "[LA] tap nav: alarm active → snoozer at index \(snoozerIndex)", isDebug: true) targetIndex = snoozerIndex } else { + LogManager.shared.log(category: .general, message: "[LA] tap nav: no alarm (or snoozer not found) → home", isDebug: true) targetIndex = 0 } @@ -722,12 +724,23 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele guard let tabBarController = tabBarController, let viewControllers = tabBarController.viewControllers else { return nil } + // First: search for SnoozerViewController directly in the tab bar. for (index, vc) in viewControllers.enumerated() { if let _ = vc as? SnoozerViewController { return index } } + // Fallback: derive the expected index from the stored snoozer position. + // When snoozer is in the menu (.menu), tabIndex == 4 — the Menu tab. + // This is better than falling back to Home (0) because the user can at + // least reach the snoozer from the menu tab in one tap. + let position = Storage.shared.snoozerPosition.value.normalized + if let tabIndex = position.tabIndex, tabIndex < viewControllers.count { + LogManager.shared.log(category: .general, message: "[LA] tap nav: snoozer not a direct tab — using stored position tabIndex=\(tabIndex)", isDebug: true) + return tabIndex + } + return nil } diff --git a/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift new file mode 100644 index 000000000..fca7a84cc --- /dev/null +++ b/LoopFollow/WatchComplication/ComplicationEntryBuilder.swift @@ -0,0 +1,239 @@ +// LoopFollow +// ComplicationEntryBuilder.swift + +import ClockKit + +// MARK: - Complication identifiers + +enum ComplicationID { + /// graphicCircular + graphicCorner with gauge arc (Complication 1). + static let gaugeCorner = "LoopFollowGaugeCorner" + /// graphicCorner stacked text only (Complication 2). + static let stackCorner = "LoopFollowStackCorner" + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, never shipped in release builds. + // Shows two timestamps to isolate pipeline failures: + // outer (top): HH:mm of snapshot.updatedAt — when CGM data last reached the Watch + // inner (↺): HH:mm when ClockKit last called getCurrentTimelineEntry + // If outer changes but inner is stale → reloadTimeline() not firing or ClockKit ignoring it. + // If inner changes but outer is stale → data delivery broken, complication rebuilding with old data. + static let debugCorner = "LoopFollowDebugCorner" + #endif +} + +// MARK: - Entry builder + +enum ComplicationEntryBuilder { + // MARK: - Live template + + static func template( + for family: CLKComplicationFamily, + snapshot: GlucoseSnapshot, + identifier: String + ) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return graphicCircularTemplate(snapshot: snapshot) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: return graphicCornerStackTemplate(snapshot: snapshot) + #if DEBUG + case ComplicationID.debugCorner: return graphicCornerDebugTemplate(snapshot: snapshot) + #endif + default: return graphicCornerGaugeTemplate(snapshot: snapshot) + } + default: + return nil + } + } + + // MARK: - Stale template + + static func staleTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "--"), + line2TextProvider: CLKSimpleTextProvider(text: "") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "STALE"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif + default: + return staleGaugeTemplate() + } + default: + return nil + } + } + + // MARK: - Placeholder template + + static func placeholderTemplate(for family: CLKComplicationFamily, identifier: String) -> CLKComplicationTemplate? { + switch family { + case .graphicCircular: + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: CLKSimpleTextProvider(text: "---"), + line2TextProvider: CLKSimpleTextProvider(text: "→") + ) + case .graphicCorner: + switch identifier { + case ComplicationID.stackCorner: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "→ --"), + outerTextProvider: outer + ) + #if DEBUG + case ComplicationID.debugCorner: + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "DEBUG"), + outerTextProvider: CLKSimpleTextProvider(text: "--:--") + ) + #endif + default: + let outer = CLKSimpleTextProvider(text: "---") + outer.tintColor = .green + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .green, fillFraction: 0) + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: "0"), + trailingTextProvider: nil, + outerTextProvider: outer + ) + } + default: + return nil + } + } + + // MARK: - Graphic Circular + + // BG (top, colored) + trend arrow (bottom). + + private static func graphicCircularTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + return CLKComplicationTemplateGraphicCircularStackText( + line1TextProvider: bgText, + line2TextProvider: CLKSimpleTextProvider(text: WatchFormat.trendArrow(snapshot)) + ) + } + + // MARK: - Graphic Corner — Gauge Text (Complication 1) + + // Gauge arc fills from 0 (fresh) to 100% (15 min stale). + // Outer text: BG (colored). Leading text: delta. + // Stale / isNotLooping → "⚠" in yellow, gauge full. + + private static func graphicCornerGaugeTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return staleGaugeTemplate() + } + + let fraction = Float(min(snapshot.age / 900.0, 1.0)) + let color = thresholdColor(for: snapshot) + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = color + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: color, fillFraction: fraction) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: CLKSimpleTextProvider(text: WatchFormat.delta(snapshot)), + trailingTextProvider: nil, + outerTextProvider: bgText + ) + } + + private static func staleGaugeTemplate() -> CLKComplicationTemplate { + let warnText = CLKSimpleTextProvider(text: "⚠") + warnText.tintColor = .yellow + + let gauge = CLKSimpleGaugeProvider(style: .fill, gaugeColor: .yellow, fillFraction: 1.0) + + return CLKComplicationTemplateGraphicCornerGaugeText( + gaugeProvider: gauge, + leadingTextProvider: nil, + trailingTextProvider: nil, + outerTextProvider: warnText + ) + } + + // MARK: - Graphic Corner — Stacked Text (Complication 2) + + // Outer (top, large): BG value, colored. + // Inner (bottom, small): "→ projected" (falls back to delta if no projection). + // Stale / isNotLooping: outer = "--", inner = "". + + private static func graphicCornerStackTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + guard snapshot.age < 900, !snapshot.isNotLooping else { + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: ""), + outerTextProvider: CLKSimpleTextProvider(text: "--") + ) + } + + let bgText = CLKSimpleTextProvider(text: WatchFormat.glucose(snapshot)) + bgText.tintColor = thresholdColor(for: snapshot) + + let bottomLabel: String + if snapshot.projected != nil { + // ⇢ = dashed arrow (U+21E2); swap for ▸ (U+25B8) if it renders poorly on-device + bottomLabel = "\(WatchFormat.delta(snapshot)) | ⇢\(WatchFormat.projected(snapshot))" + } else { + bottomLabel = WatchFormat.delta(snapshot) + } + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: bottomLabel), + outerTextProvider: bgText + ) + } + + // MARK: - Graphic Corner — Debug (Complication 3, DEBUG builds only) + + // Outer (top): HH:mm of the snapshot's updatedAt — when the CGM reading arrived. + // Inner (bottom): "↺ HH:mm" — when ClockKit last called getCurrentTimelineEntry. + // + // Reading the two times tells you: + // outer changes → Watch is receiving new data + // inner changes → ClockKit is refreshing the complication face + // inner stale → reloadTimeline is not being called or ClockKit is ignoring it + + #if DEBUG + private static func graphicCornerDebugTemplate(snapshot: GlucoseSnapshot) -> CLKComplicationTemplate { + let dataTime = WatchFormat.updateTime(snapshot) + let buildTime = WatchFormat.currentTime() + + return CLKComplicationTemplateGraphicCornerStackText( + innerTextProvider: CLKSimpleTextProvider(text: "↺ \(buildTime)"), + outerTextProvider: CLKSimpleTextProvider(text: dataTime) + ) + } + #endif + + // MARK: - Threshold color + + /// snapshot.glucose is always in mg/dL (builder stores canonical mg/dL). + static func thresholdColor(for snapshot: GlucoseSnapshot) -> UIColor { + let t = LAAppGroupSettings.thresholdsMgdl() + if snapshot.glucose < t.low { return .red } + if snapshot.glucose > t.high { return .orange } + return .green + } +} diff --git a/LoopFollow/WatchComplication/WatchComplicationProvider.swift b/LoopFollow/WatchComplication/WatchComplicationProvider.swift new file mode 100644 index 000000000..755fc3dae --- /dev/null +++ b/LoopFollow/WatchComplication/WatchComplicationProvider.swift @@ -0,0 +1,129 @@ +// LoopFollow +// WatchComplicationProvider.swift + +import ClockKit +import Foundation +import os.log + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchComplicationProvider: NSObject, CLKComplicationDataSource { + // MARK: - Complication Descriptors + + func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) { + var descriptors: [CLKComplicationDescriptor] = [ + // Complication 1: BG + gauge arc (graphicCircular + graphicCorner) + CLKComplicationDescriptor( + identifier: ComplicationID.gaugeCorner, + displayName: "LoopFollow", + supportedFamilies: [.graphicCircular, .graphicCorner] + ), + // Complication 2: BG + projected BG stacked text (graphicCorner only) + CLKComplicationDescriptor( + identifier: ComplicationID.stackCorner, + displayName: "LoopFollow Text", + supportedFamilies: [.graphicCorner] + ), + ] + #if DEBUG + // DEBUG COMPLICATION — pipeline diagnostics only, not shipped in release builds. + descriptors.append( + CLKComplicationDescriptor( + identifier: ComplicationID.debugCorner, + displayName: "LoopFollow Debug", + supportedFamilies: [.graphicCorner] + ) + ) + #endif + handler(descriptors) + } + + // MARK: - Timeline + + func getCurrentTimelineEntry( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void + ) { + // Whenever ClockKit calls us it hands us the CLKComplication object. + // Cache it so WatchSessionReceiver can call reloadTimeline() even when + // activeComplications is nil (common during background execution on watchOS 9+). + WatchSessionReceiver.shared.cacheComplication(complication) + + // Prefer the file store (persists across launches); fall back to the in-memory + // cache in case the file write hasn't completed or the store is unavailable. + guard let snapshot = GlucoseSnapshotStore.shared.load() + ?? WatchSessionReceiver.shared.lastSnapshot + else { + os_log("WatchComplicationProvider: no snapshot available (store and cache both nil)", log: watchLog, type: .error) + handler(nil) + return + } + + os_log("WatchComplicationProvider: getCurrentTimelineEntry g=%d age=%ds id=%{public}@", log: watchLog, type: .info, Int(snapshot.glucose), Int(snapshot.age), complication.identifier) + + guard snapshot.age < 900 else { + os_log("WatchComplicationProvider: snapshot stale (%d s)", log: watchLog, type: .debug, Int(snapshot.age)) + handler(staleEntry(for: complication)) + return + } + + let template = ComplicationEntryBuilder.template( + for: complication.family, + snapshot: snapshot, + identifier: complication.identifier + ) + let entry = template.map { + CLKComplicationTimelineEntry(date: snapshot.updatedAt, complicationTemplate: $0) + } + handler(entry) + } + + func getTimelineEndDate( + for complication: CLKComplication, + withHandler handler: @escaping (Date?) -> Void + ) { + WatchSessionReceiver.shared.cacheComplication(complication) + // Expire timeline 15 minutes after last reading + // so Watch does not display indefinitely stale data + if let snapshot = GlucoseSnapshotStore.shared.load() { + handler(snapshot.updatedAt.addingTimeInterval(900)) + } else { + handler(nil) + } + } + + func getPrivacyBehavior( + for _: CLKComplication, + withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void + ) { + // Glucose is sensitive — hide on locked watch face + handler(.hideOnLockScreen) + } + + // MARK: - Placeholder + + func getLocalizableSampleTemplate( + for complication: CLKComplication, + withHandler handler: @escaping (CLKComplicationTemplate?) -> Void + ) { + handler(ComplicationEntryBuilder.placeholderTemplate( + for: complication.family, + identifier: complication.identifier + )) + } + + // MARK: - Private + + private func staleEntry(for complication: CLKComplication) -> CLKComplicationTimelineEntry? { + let template = ComplicationEntryBuilder.staleTemplate( + for: complication.family, + identifier: complication.identifier + ) + return template.map { + CLKComplicationTimelineEntry(date: Date(), complicationTemplate: $0) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchFormat.swift b/LoopFollow/WatchComplication/WatchFormat.swift new file mode 100644 index 000000000..194857e38 --- /dev/null +++ b/LoopFollow/WatchComplication/WatchFormat.swift @@ -0,0 +1,195 @@ +// LoopFollow +// WatchFormat.swift + +import Foundation + +/// Formatting helpers for Watch complications and Watch app UI. +/// All glucose values in GlucoseSnapshot are stored in mg/dL; this module +/// converts to mmol/L for display when snapshot.unit == .mmol. +enum WatchFormat { + // MARK: - Glucose + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + if d == 0 { return "0.0" } + let str = String(format: "%.1f", abs(d)) + return d > 0 ? "+\(str)" : "-\(str)" + } + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: return "↑↑" + case .up: return "↑" + case .upSlight: return "↗" + case .flat: return "→" + case .downSlight: return "↘" + case .down: return "↓" + case .downFast: return "↓↓" + case .unknown: return "–" + } + } + + // MARK: - Time + + /// "Xm" from now, capped display at 99m + static func minAgo(_ s: GlucoseSnapshot) -> String { + let mins = Int(s.age / 60) + return "\(min(mins, 99))m" + } + + /// Time of last update formatted as "HH:mm" + static func updateTime(_ s: GlucoseSnapshot) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: s.updatedAt) + } + + static func currentTime() -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: Date()) + } + + // MARK: - Secondary metrics + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return "\(Int(round(v)))" + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secs = Date().timeIntervalSince1970 - insertTime + let days = Int(secs / 86400) + let hours = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600) + return days > 0 ? "\(days)d\(hours)h" : "\(hours)h" + } + + static func override(_ s: GlucoseSnapshot) -> String { s.override ?? "—" } + static func profileName(_ s: GlucoseSnapshot) -> String { s.profileName ?? "—" } + + // MARK: - Slot dispatch + + static func slotValue(option: LiveActivitySlotOption, snapshot s: GlucoseSnapshot) -> String { + switch option { + case .none: return "" + case .delta: return delta(s) + case .projectedBG: return projected(s) + case .minMax: return minMax(s) + case .iob: return iob(s) + case .cob: return cob(s) + case .recBolus: return recBolus(s) + case .autosens: return autosens(s) + case .tdd: return tdd(s) + case .basal: return basal(s) + case .pump: return pump(s) + case .pumpBattery: return pumpBattery(s) + case .battery: return battery(s) + case .target: return target(s) + case .isf: return isf(s) + case .carbRatio: return carbRatio(s) + case .sage: return age(insertTime: s.sageInsertTime) + case .cage: return age(insertTime: s.cageInsertTime) + case .iage: return age(insertTime: s.iageInsertTime) + case .carbsToday: return carbsToday(s) + case .override: return override(s) + case .profile: return profileName(s) + } + } + + // MARK: - Private + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return "\(Int(round(mgdl)))" + case .mmol: + return String(format: "%.1f", GlucoseConversion.toMmol(mgdl)) + } + } +} diff --git a/LoopFollow/WatchComplication/WatchSessionReceiver.swift b/LoopFollow/WatchComplication/WatchSessionReceiver.swift new file mode 100644 index 000000000..b5a5e2f8e --- /dev/null +++ b/LoopFollow/WatchComplication/WatchSessionReceiver.swift @@ -0,0 +1,219 @@ +// LoopFollow +// WatchSessionReceiver.swift + +import ClockKit +import Foundation +import os.log +import WatchConnectivity +import WatchKit + +private let watchLog = OSLog( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +final class WatchSessionReceiver: NSObject { + // MARK: - Shared Instance + + static let shared = WatchSessionReceiver() + + static let snapshotReceivedNotification = Notification.Name("WatchSnapshotReceived") + + /// Held open while WatchConnectivity delivers a pending transferUserInfo in the background. + /// Completed after the snapshot is saved to disk. + var pendingConnectivityTask: WKWatchConnectivityRefreshBackgroundTask? + + /// In-memory cache of the last received snapshot. Used by WatchComplicationProvider as a + /// fallback when GlucoseSnapshotStore.load() returns nil (e.g. file write race or first launch). + /// Always reflects the most recently delivered snapshot regardless of file-store state. + private(set) var lastSnapshot: GlucoseSnapshot? + + /// Cache of CLKComplication objects keyed by "-". + /// Populated when ClockKit calls getCurrentTimelineEntry (complication is on an active face) + /// or when activeComplications is non-nil. Used as a fallback when activeComplications + /// returns nil/empty during background execution — a known watchOS 9+ limitation. + private var cachedComplications: [String: CLKComplication] = [:] + + /// Called by WatchComplicationProvider whenever ClockKit passes a CLKComplication to us. + func cacheComplication(_ complication: CLKComplication) { + let key = "\(complication.identifier)-\(complication.family.rawValue)" + cachedComplications[key] = complication + } + + // MARK: - Init + + override private init() { + super.init() + } + + // MARK: - Setup + + /// Call once from the Watch extension entry point after launch. + func activate() { + guard WCSession.isSupported() else { + os_log("WatchSessionReceiver: WCSession not supported", log: watchLog, type: .debug) + return + } + WCSession.default.delegate = self + WCSession.default.activate() + os_log("WatchSessionReceiver: WCSession activation requested", log: watchLog, type: .debug) + } + + /// Triggers a complication timeline reload. Called from background refresh tasks + /// after a snapshot has already been saved to GlucoseSnapshotStore. + func triggerComplicationReload() { + reloadComplications() + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionReceiver: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error = error { + os_log("WatchSessionReceiver: activation failed — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } else { + os_log("WatchSessionReceiver: activation complete — state %d", log: watchLog, type: .debug, activationState.rawValue) + bootstrapFromApplicationContext(session) + } + } + + /// Loads a snapshot from the last received applicationContext so the Watch app + /// has data immediately on launch without waiting for the next transferUserInfo. + private func bootstrapFromApplicationContext(_ session: WCSession) { + guard let data = session.receivedApplicationContext["snapshot"] as? Data else { return } + do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. + let decoder = JSONDecoder() + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: bootstrapped snapshot from applicationContext", log: watchLog, type: .debug) + self?.reloadComplications() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: failed to decode applicationContext snapshot — %{public}@", log: watchLog, type: .error, error.localizedDescription) + } + } + + /// Handles immediate delivery when Watch app is in foreground (sendMessage path). + func session( + _: WCSession, + didReceiveMessage message: [String: Any] + ) { + process(payload: message, source: "sendMessage") + } + + /// Handles queued background delivery (transferUserInfo path). + func session( + _: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + process(payload: userInfo, source: "userInfo") + } + + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + process(payload: applicationContext, source: "applicationContext") + } + + // MARK: - Private + + private func process(payload: [String: Any], source: String) { + guard let data = payload["snapshot"] as? Data else { + os_log("WatchSessionReceiver: %{public}@ — no snapshot key", log: watchLog, type: .debug, source) + return + } + do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. + let decoder = JSONDecoder() + let snapshot = try decoder.decode(GlucoseSnapshot.self, from: data) + // Cache in memory immediately — complication provider can use this as a + // fallback if the App Group file store hasn't flushed yet. + lastSnapshot = snapshot + os_log("WatchSessionReceiver: %{public}@ snapshot decoded g=%d, saving", log: watchLog, type: .debug, source, Int(snapshot.glucose)) + GlucoseSnapshotStore.shared.save(snapshot) { [weak self] in + os_log("WatchSessionReceiver: %{public}@ snapshot saved, reloading complications", log: watchLog, type: .debug, source) + // ACK to iPhone so it can detect missed deliveries. + self?.sendAck(for: snapshot) + // Capture and clear the pending task before dispatching to main, + // then complete it AFTER reloadTimeline() so watchOS doesn't suspend + // the extension before ClockKit processes the reload request. + let task = self?.pendingConnectivityTask + self?.pendingConnectivityTask = nil + DispatchQueue.main.async { [weak self] in + self?.reloadComplicationsOnMainThread() + // Complete background task only after reloadTimeline() has been called. + task?.setTaskCompletedWithSnapshot(false) + NotificationCenter.default.post( + name: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + userInfo: ["snapshot": snapshot] + ) + } + } + } catch { + os_log("WatchSessionReceiver: %{public}@ decode failed — %{public}@", log: watchLog, type: .error, source, error.localizedDescription) + } + } + + private func sendAck(for snapshot: GlucoseSnapshot) { + let session = WCSession.default + guard session.activationState == .activated else { return } + let ack: [String: Any] = ["watchAck": snapshot.updatedAt.timeIntervalSince1970] + if session.isReachable { + session.sendMessage(ack, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(ack) + } + os_log("WatchSessionReceiver: ACK sent for snapshot at %f", log: watchLog, type: .debug, snapshot.updatedAt.timeIntervalSince1970) + } + + /// Reloads all known complications. May be called from any thread. + func reloadComplications() { + DispatchQueue.main.async { self.reloadComplicationsOnMainThread() } + } + + /// Must be called on the main thread. Used directly when already on main (e.g., from process()). + private func reloadComplicationsOnMainThread() { + let server = CLKComplicationServer.sharedInstance() + + let complications: [CLKComplication] + if let active = server.activeComplications, !active.isEmpty { + // Update the cache whenever activeComplications is non-nil. + active.forEach { self.cacheComplication($0) } + complications = active + os_log("WatchSessionReceiver: reloading %d active complication(s)", log: watchLog, type: .info, complications.count) + } else if !cachedComplications.isEmpty { + // activeComplications is nil/empty — common during background execution on watchOS 9+. + // Use the cached CLKComplication objects from the last call where activeComplications was valid. + complications = Array(cachedComplications.values) + os_log("WatchSessionReceiver: activeComplications nil/empty — using %d cached complication(s)", log: watchLog, type: .info, complications.count) + } else { + os_log("WatchSessionReceiver: no active or cached complications — reloadTimeline skipped", log: watchLog, type: .error) + return + } + + for complication in complications { + server.reloadTimeline(for: complication) + } + os_log("WatchSessionReceiver: reloadTimeline called for %d complication(s)", log: watchLog, type: .info, complications.count) + } + + // NOTE: reloadComplications() is safe to call from any thread for foreground paths + // (bootstrap, reloadComplicationsIfNeeded). For background task paths (process()), + // setTaskCompletedWithSnapshot() must be called INSIDE DispatchQueue.main.async + // after reloadTimeline() — otherwise watchOS suspends the extension before ClockKit + // receives the reload request. +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png new file mode 100644 index 000000000..0eaf8706b Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-dark.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png new file mode 100644 index 000000000..61aaa37f1 Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024-tinted.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..a975ee063 Binary files /dev/null and b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..113d82165 --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/Assets.xcassets/Contents.json b/LoopFollowWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LoopFollowWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch Watch App/ContentView.swift b/LoopFollowWatch Watch App/ContentView.swift new file mode 100644 index 000000000..b9c9066d6 --- /dev/null +++ b/LoopFollowWatch Watch App/ContentView.swift @@ -0,0 +1,234 @@ +// LoopFollow +// ContentView.swift + +import Combine +import SwiftUI +import WatchConnectivity + +// MARK: - Root view + +struct ContentView: View { + @StateObject private var model = WatchViewModel() + + var body: some View { + TabView { + GlucoseView(model: model) + + ForEach(Array(model.pages.enumerated()), id: \.offset) { _, page in + DataGridPage(slots: page, snapshot: model.snapshot) + } + + SlotSelectionView(model: model) + } + .tabViewStyle(.page) + .onAppear { model.refresh() } + } +} + +// MARK: - View model + +final class WatchViewModel: ObservableObject { + @Published var snapshot: GlucoseSnapshot? + @Published var selectedSlots: [LiveActivitySlotOption] = LAAppGroupSettings.watchSelectedSlots() + + private var timer: Timer? + private var notificationObserver: Any? + + init() { + snapshot = GlucoseSnapshotStore.shared.load() + timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.refresh() + } + // Subscribe in init so notifications fired before the view appears are not missed. + notificationObserver = NotificationCenter.default.addObserver( + forName: WatchSessionReceiver.snapshotReceivedNotification, + object: nil, + queue: .main + ) { [weak self] notification in + if let s = notification.userInfo?["snapshot"] as? GlucoseSnapshot { + self?.update(snapshot: s) + } else { + self?.refresh() + } + } + } + + deinit { + timer?.invalidate() + if let obs = notificationObserver { + NotificationCenter.default.removeObserver(obs) + } + } + + func refresh() { + if let loaded = GlucoseSnapshotStore.shared.load() { + snapshot = loaded + } + selectedSlots = LAAppGroupSettings.watchSelectedSlots() + } + + func update(snapshot: GlucoseSnapshot) { + self.snapshot = snapshot + selectedSlots = LAAppGroupSettings.watchSelectedSlots() + } + + /// Slots grouped into pages of 4 for the swipable grid tabs. + var pages: [[LiveActivitySlotOption]] { + guard !selectedSlots.isEmpty else { return [] } + return stride(from: 0, to: selectedSlots.count, by: 4).map { + Array(selectedSlots[$0 ..< min($0 + 4, selectedSlots.count)]) + } + } + + func isSelected(_ option: LiveActivitySlotOption) -> Bool { + selectedSlots.contains(option) + } + + func toggleSlot(_ option: LiveActivitySlotOption) { + if let idx = selectedSlots.firstIndex(of: option) { + selectedSlots.remove(at: idx) + } else { + selectedSlots.append(option) + } + LAAppGroupSettings.setWatchSelectedSlots(selectedSlots) + } +} + +// MARK: - Page 1: Glucose + +struct GlucoseView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + if let s = model.snapshot, s.age < 900 { + VStack(alignment: .leading, spacing: 6) { + // Large BG + trend arrow, single line, auto-scaled to fit 7 chars + Text("\(WatchFormat.glucose(s)) \(WatchFormat.trendArrow(s))") + .font(.system(size: 56, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.4) + .lineLimit(1) + .foregroundColor(ComplicationEntryBuilder.thresholdColor(for: s).swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 3) { + Text("Delta: \(WatchFormat.delta(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.projected != nil { + Text("Projected: \(WatchFormat.projected(s)) \(s.unit.displayName)") + .font(.system(size: 14)) + .foregroundColor(.white) + } + + Text("Last update: \(WatchFormat.updateTime(s))") + .font(.system(size: 14)) + .foregroundColor(.white) + + if s.isNotLooping { + Text("⚠ Loop inactive") + .font(.system(size: 12)) + .foregroundColor(.yellow) + } + } + } + .padding(.horizontal, 4) + } else { + VStack(spacing: 4) { + Text("--") + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .foregroundColor(.secondary) + Text(model.snapshot == nil ? "No data" : "Stale") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + +// MARK: - Data grid page (2×2, up to 4 slots) + +struct DataGridPage: View { + let slots: [LiveActivitySlotOption] + let snapshot: GlucoseSnapshot? + + var body: some View { + LazyVGrid( + columns: [GridItem(.flexible()), GridItem(.flexible())], + spacing: 8 + ) { + ForEach(0 ..< 4, id: \.self) { i in + if i < slots.count { + let option = slots[i] + MetricCell( + label: option.gridLabel, + value: snapshot.map { WatchFormat.slotValue(option: option, snapshot: $0) } ?? "—" + ) + } else { + Color.clear.frame(height: 52) + } + } + } + .padding(.horizontal, 4) + } +} + +// MARK: - Metric cell + +struct MetricCell: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 2) { + Text(label) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + Text(value) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity) + .padding(6) + .background(Color.secondary.opacity(0.15)) + .cornerRadius(8) + } +} + +// MARK: - Last tab: slot selection checklist + +struct SlotSelectionView: View { + @ObservedObject var model: WatchViewModel + + var body: some View { + List { + // `.delta` and `.projectedBG` are always shown on the glucose page, + // so they're excluded from the grid slot picker to avoid duplication. + ForEach(LiveActivitySlotOption.allCases.filter { $0 != .none && $0 != .delta && $0 != .projectedBG }, id: \.self) { option in + Button(action: { model.toggleSlot(option) }) { + HStack { + Text(option.displayName) + .foregroundColor(.primary) + Spacer() + Image( + systemName: model.isSelected(option) + ? "checkmark.circle.fill" + : "circle" + ) + .foregroundColor(model.isSelected(option) ? .green : .secondary) + } + } + .buttonStyle(.plain) + } + } + .navigationTitle("Data") + } +} + +// MARK: - UIColor → SwiftUI Color bridge + +private extension UIColor { + var swiftUIColor: Color { Color(self) } +} diff --git a/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch Watch App/LoopFollowWatchApp.swift b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift new file mode 100644 index 000000000..aec3e0549 --- /dev/null +++ b/LoopFollowWatch Watch App/LoopFollowWatchApp.swift @@ -0,0 +1,104 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import OSLog +import SwiftUI +import WatchConnectivity +import WatchKit + +private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.loopfollow.watch", + category: "Watch" +) + +@main +struct LoopFollowWatch_Watch_AppApp: App { + @WKApplicationDelegateAdaptor(WatchAppDelegate.self) var delegate + + init() { + WatchSessionReceiver.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +// MARK: - App delegate for background tasks + +final class WatchAppDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + WatchAppDelegate.scheduleNextRefresh() + } + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let refreshTask as WKApplicationRefreshBackgroundTask: + handleRefresh(refreshTask) + + case let connectivityTask as WKWatchConnectivityRefreshBackgroundTask: + // Hold the task open — WatchConnectivity will deliver the pending + // transferUserInfo to session(_:didReceiveUserInfo:) while the app + // is awake. WatchSessionReceiver completes it after saving the snapshot. + WatchSessionReceiver.shared.pendingConnectivityTask = connectivityTask + + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } + + private func handleRefresh(_ task: WKApplicationRefreshBackgroundTask) { + // receivedApplicationContext always holds the last value the iPhone sent — + // no active Bluetooth or WKWatchConnectivityRefreshBackgroundTask needed. + // If it's newer than what's in the file store, persist it and reload complications. + let contextSnapshot = Self.decodeContextSnapshot() + let storeSnapshot = GlucoseSnapshotStore.shared.load() + + if let ctx = contextSnapshot, + ctx.updatedAt > (storeSnapshot?.updatedAt ?? .distantPast) + { + GlucoseSnapshotStore.shared.save(ctx) { + WatchSessionReceiver.shared.triggerComplicationReload() + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } else { + if storeSnapshot != nil { + WatchSessionReceiver.shared.triggerComplicationReload() + } + WatchAppDelegate.scheduleNextRefresh() + task.setTaskCompletedWithSnapshot(false) + } + } + + static func decodeContextSnapshot() -> GlucoseSnapshot? { + guard let data = WCSession.default.receivedApplicationContext["snapshot"] as? Data else { + return nil + } + + do { + // GlucoseSnapshot has a custom decoder that reads `updatedAt` as a + // Double, so no JSONDecoder date strategy is required. + let decoder = JSONDecoder() + return try decoder.decode(GlucoseSnapshot.self, from: data) + } catch { + logger.error("WatchAppDelegate: failed to decode applicationContext snapshot — \(error.localizedDescription, privacy: .public)") + return nil + } + } + + static func scheduleNextRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date(timeIntervalSinceNow: 5 * 60), + userInfo: nil + ) { _ in } + } + + private func scheduleNextRefresh() { + WatchAppDelegate.scheduleNextRefresh() + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0871e7414..6aa54cfbb 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -56,7 +56,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) @@ -72,12 +73,19 @@ platform :ios do ) update_code_signing_settings( - path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], code_sign_identity: "iPhone Distribution", targets: ["LoopFollowLAExtensionExtension"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.watchkitapp"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch Watch App"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", @@ -88,9 +96,10 @@ platform :ios do export_options: { provisioningProfiles: { "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + "com.#{TEAMID}.LoopFollow.watchkitapp" => mapping["com.#{TEAMID}.LoopFollow.watchkitapp"] } - } + } ) copy_artifacts( @@ -141,9 +150,11 @@ platform :ios do configure_bundle_id("LoopFollow", "com.#{TEAMID}.LoopFollow", [ Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS ]) - + configure_bundle_id("LoopFollow Live Activity Extension", "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", []) + configure_bundle_id("LoopFollow Watch App", "com.#{TEAMID}.LoopFollow.watchkitapp", []) + end desc "Provision Certificates" @@ -164,7 +175,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.watchkitapp" ] ) end