diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index c76e57915..1153fbba2 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -64,7 +64,13 @@ 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; + 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; + A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; + A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; ACE7F6DE0D065BEB52CDC0DB /* FutureCarbsAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */; }; + BB0100000000000B000000AA /* LoopFollowWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = BB01000000000001000000AA /* LoopFollowWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BB0100000000000F000000AA /* PhoneSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0100000000000E000000AA /* PhoneSessionManager.swift */; }; + CC01000000000001000000AA /* LoopFollowWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CC01000000000002000000AA /* LoopFollowWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; @@ -250,7 +256,6 @@ DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; }; DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; }; DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; }; - A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; }; DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; }; DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; }; DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; }; @@ -291,7 +296,6 @@ FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1BDD2E24A232A3001B652C /* DataStructs.swift */; }; FC3AE7B5249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */; }; FC3CAB022493B6220068A152 /* BackgroundTaskAudio.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC688592489554800A0279D /* BackgroundTaskAudio.swift */; }; - A1A1A10001000000A0CFEED1 /* APNsCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FC7CE518248ABE37001F83B8 /* Siri_Alert_Calibration_Needed.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4A9248ABE2B001F83B8 /* Siri_Alert_Calibration_Needed.caf */; }; FC7CE519248ABE37001F83B8 /* Rise_And_Shine.caf in Resources */ = {isa = PBXBuildFile; fileRef = FC7CE4AA248ABE2B001F83B8 /* Rise_And_Shine.caf */; }; @@ -424,7 +428,6 @@ FCC6886B24898FD800A0279D /* ObservationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886A24898FD800A0279D /* ObservationToken.swift */; }; FCC6886D2489909D00A0279D /* AnyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886C2489909D00A0279D /* AnyConvertible.swift */; }; FCC6886F2489A53800A0279D /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC6886E2489A53800A0279D /* AppConstants.swift */; }; - 9C7FB98C98BE4FF98F4815EE /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFBE69CEF18416D84959974 /* Telemetry.swift */; }; FCD2A27D24C9D044009F7B7B /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD2A27C24C9D044009F7B7B /* Globals.swift */; }; FCE537BC249A4D7D00F80BF8 /* carbBolusArrays.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */; }; FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCEF87AA24A1417900AE6FA0 /* Localizer.swift */; }; @@ -441,6 +444,20 @@ remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289; remoteInfo = LoopFollowLAExtensionExtension; }; + BB0100000000000D000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = BB01000000000003000000AA; + remoteInfo = LoopFollowWatch; + }; + CC01000000000010000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = CC01000000000003000000AA; + remoteInfo = LoopFollowWidgets; + }; DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -462,6 +479,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + BB0100000000000A000000AA /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + BB0100000000000B000000AA /* LoopFollowWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000020000000AA /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + CC01000000000001000000AA /* LoopFollowWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -517,9 +556,15 @@ 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.swift; sourceTree = ""; }; 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; + A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; + A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; B7D2A4EFD18B7B7748B6669E /* FutureCarbsAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsAlarmEditor.swift; sourceTree = ""; }; + BB01000000000001000000AA /* LoopFollowWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoopFollowWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BB0100000000000E000000AA /* PhoneSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneSessionManager.swift; sourceTree = ""; }; + BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; + CC01000000000002000000AA /* LoopFollowWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; @@ -707,7 +752,6 @@ DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = ""; }; DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = ""; }; DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = ""; }; - A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = ""; }; DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = ""; }; DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = ""; }; DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = ""; }; @@ -876,7 +920,6 @@ FCA2DDE52501095000254A8C /* Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timers.swift; sourceTree = ""; }; FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryKeyPath.swift; sourceTree = ""; }; FCC688592489554800A0279D /* BackgroundTaskAudio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundTaskAudio.swift; sourceTree = ""; }; - A1A1A10001000000A0CFEED2 /* APNsCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNsCredentialValidator.swift; sourceTree = ""; }; FCC6885B2489559400A0279D /* blank.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blank.wav; sourceTree = ""; }; FCC6885D24896A6C00A0279D /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; FCC6886624898F8000A0279D /* UserDefaultsValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsValue.swift; sourceTree = ""; }; @@ -884,7 +927,6 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationToken.swift; sourceTree = ""; }; FCC6886C2489909D00A0279D /* AnyConvertible.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyConvertible.swift; sourceTree = ""; }; FCC6886E2489A53800A0279D /* AppConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; - BDFBE69CEF18416D84959974 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = ""; }; FCC688702489A57C00A0279D /* Loop Follow.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Follow.entitlements"; sourceTree = ""; }; FCD2A27C24C9D044009F7B7B /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; FCE537BB249A4D7D00F80BF8 /* carbBolusArrays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbBolusArrays.swift; sourceTree = ""; }; @@ -895,10 +937,29 @@ FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BB01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = BB01000000000003000000AA /* LoopFollowWatch */; + }; + CC01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CC01000000000003000000AA /* LoopFollowWidgets */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 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 = ""; }; + BB01000000000002000000AA /* LoopFollowWatch */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (BB01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopFollowWatch; sourceTree = ""; }; + CC01000000000004000000AA /* LoopFollowWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CC01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopFollowWidgets; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -912,6 +973,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000006000000AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000006000000AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -931,6 +1006,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 115EAFAA2FAD881100BF4FAF /* Recovered References */ = { + isa = PBXGroup; + children = ( + CC01000000000002000000AA /* LoopFollowWidgets.appex */, + CC01000000000004000000AA /* LoopFollowWidgets */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 376310762F5CD65100656488 /* LiveActivity */ = { isa = PBXGroup; children = ( @@ -1007,6 +1091,14 @@ path = Pods; sourceTree = ""; }; + BB01000000000010000000AA /* Watch */ = { + isa = PBXGroup; + children = ( + BB0100000000000E000000AA /* PhoneSessionManager.swift */, + ); + path = Watch; + sourceTree = ""; + }; DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = { isa = PBXGroup; children = ( @@ -1625,6 +1717,7 @@ DDC7E5142DBCE1B900EB1127 /* Snoozer */, DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, + BB01000000000010000000AA /* Watch */, FCC68871248A736700A0279D /* ViewControllers */, ); path = LoopFollow; @@ -1643,10 +1736,12 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + BB01000000000002000000AA /* LoopFollowWatch */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, + 115EAFAA2FAD881100BF4FAF /* Recovered References */, ); sourceTree = ""; }; @@ -1656,6 +1751,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + BB01000000000001000000AA /* LoopFollowWatch.app */, ); name = Products; sourceTree = ""; @@ -1754,6 +1850,52 @@ productReference = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + BB01000000000003000000AA /* LoopFollowWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; + buildPhases = ( + BB01000000000004000000AA /* Sources */, + BB01000000000006000000AA /* Frameworks */, + BB01000000000005000000AA /* Resources */, + CC01000000000020000000AA /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + CC01000000000012000000AA /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BB01000000000002000000AA /* LoopFollowWatch */, + ); + name = LoopFollowWatch; + packageProductDependencies = ( + ); + productName = LoopFollowWatch; + productReference = BB01000000000001000000AA /* LoopFollowWatch.app */; + productType = "com.apple.product-type.application"; + }; + CC01000000000003000000AA /* LoopFollowWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = CC01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWidgets" */; + buildPhases = ( + CC01000000000005000000AA /* Sources */, + CC01000000000006000000AA /* Frameworks */, + CC01000000000007000000AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CC01000000000004000000AA /* LoopFollowWidgets */, + ); + name = LoopFollowWidgets; + packageProductDependencies = ( + ); + productName = LoopFollowWidgets; + productReference = CC01000000000002000000AA /* LoopFollowWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; DDCC3AD52DDE1790006F1C10 /* Tests */ = { isa = PBXNativeTarget; buildConfigurationList = DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */; @@ -1789,11 +1931,13 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + BB0100000000000A000000AA /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + BB0100000000000C000000AA /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1819,6 +1963,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + CC01000000000003000000AA = { + CreatedOnToolsVersion = 16.3; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1846,6 +1993,8 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + BB01000000000003000000AA /* LoopFollowWatch */, + CC01000000000003000000AA /* LoopFollowWidgets */, ); }; /* End PBXProject section */ @@ -1858,6 +2007,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000005000000AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000007000000AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD42DDE1790006F1C10 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2095,6 +2258,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000004000000AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000005000000AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD22DDE1790006F1C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2395,6 +2572,7 @@ DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, + BB0100000000000F000000AA /* PhoneSessionManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2407,6 +2585,17 @@ target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; }; + BB0100000000000C000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = BB01000000000003000000AA /* LoopFollowWatch */; + targetProxy = BB0100000000000D000000AA /* PBXContainerItemProxy */; + }; + CC01000000000012000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CC01000000000003000000AA /* LoopFollowWidgets */; + targetProxy = CC01000000000010000000AA /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2445,7 +2634,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2497,7 +2686,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2537,6 +2726,106 @@ }; name = Release; }; + BB01000000000007000000AA /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC5A5C3C2497B229009C550E /* Config.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + MARKETING_VERSION = "$(LOOP_FOLLOW_MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = LoopFollowWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + BB01000000000008000000AA /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC5A5C3C2497B229009C550E /* Config.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + MARKETING_VERSION = "$(LOOP_FOLLOW_MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = LoopFollowWatch; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + CC01000000000007000000BB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC5A5C3C2497B229009C550E /* Config.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgets/LoopFollowWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidgets/Info.plist; + MARKETING_VERSION = "$(LOOP_FOLLOW_MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWidgets; + PRODUCT_NAME = LoopFollowWidgets; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + CC01000000000008000000BB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC5A5C3C2497B229009C550E /* Config.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgets/LoopFollowWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidgets/Info.plist; + MARKETING_VERSION = "$(LOOP_FOLLOW_MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWidgets; + PRODUCT_NAME = LoopFollowWidgets; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; DDCC3ADD2DDE1790006F1C10 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2546,7 +2835,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -2579,7 +2868,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; ENABLE_USER_SCRIPT_SANDBOXING = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -2731,7 +3020,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2756,7 +3045,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2784,6 +3073,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB01000000000007000000AA /* Debug */, + BB01000000000008000000AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CC01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC01000000000007000000BB /* Debug */, + CC01000000000008000000BB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme new file mode 100644 index 000000000..fae7819ae --- /dev/null +++ b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 831395f02..bfe72d6f8 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -5,6 +5,7 @@ import CoreData import EventKit import UIKit import UserNotifications +import WatchConnectivity @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -70,6 +71,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Storage.shared.needsBFUReload = bfu LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + PhoneSessionManager.shared.startSession() + return true } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index d97aba24c..83f49021a 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -291,6 +291,8 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() + + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 9b336be2b..4a91aefc9 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -25,6 +25,7 @@ class NightscoutSettingsViewModel: ObservableObject { if newValue != nightscoutURL { Storage.shared.url.value = newValue triggerCheckStatus() + PhoneSessionManager.shared.sendConfig() } } } @@ -34,6 +35,7 @@ class NightscoutSettingsViewModel: ObservableObject { if newValue != nightscoutToken { Storage.shared.token.value = newValue triggerCheckStatus() + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index c05f041a2..925a152fa 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -160,7 +160,10 @@ class RemoteSettingsViewModel: ObservableObject { $mealWithFatProtein .dropFirst() - .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 } + .sink { [weak self] in + self?.storage.mealWithFatProtein.value = $0 + PhoneSessionManager.shared.sendConfig() + } .store(in: &cancellables) // Device type monitoring diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 8c7fd5324..d0511d4d3 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -12,6 +12,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != userName { Storage.shared.shareUserName.value = newValue + PhoneSessionManager.shared.sendConfig() } } } @@ -20,6 +21,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != password { Storage.shared.sharePassword.value = newValue + PhoneSessionManager.shared.sendConfig() } } } @@ -28,6 +30,7 @@ class DexcomSettingsViewModel: ObservableObject { willSet { if newValue != server { Storage.shared.shareServer.value = newValue + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 427c4e5bd..187acac1e 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -21,6 +21,7 @@ struct UnitsConfigurationView: View { .pickerStyle(.segmented) .onChange(of: glucoseUnit) { newValue in UnitSettingsStore.shared.glucoseUnit = newValue + PhoneSessionManager.shared.sendConfig() } } @@ -46,6 +47,7 @@ struct UnitsConfigurationView: View { .onChange(of: lowValue) { newValue in Storage.shared.lowLine.value = newValue Observable.shared.chartSettingsChanged.value = true + PhoneSessionManager.shared.sendConfig() } BGPicker( title: "High", @@ -56,6 +58,7 @@ struct UnitsConfigurationView: View { .onChange(of: highValue) { newValue in Storage.shared.highLine.value = newValue Observable.shared.chartSettingsChanged.value = true + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift new file mode 100644 index 000000000..9c5eb4646 --- /dev/null +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -0,0 +1,127 @@ +// LoopFollow +// PhoneSessionManager.swift + +import Foundation +import HealthKit +import WatchConnectivity + +class PhoneSessionManager: NSObject, WCSessionDelegate { + static let shared = PhoneSessionManager() + + override private init() { + super.init() + } + + func startSession() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + private func buildConfig() -> [String: Any] { + // LF (LoopFollow's own) APNS credentials — used by the watch to + // populate `return_notification` so Trio acks land here on the + // iPhone (which can then forward to the watch via WCSession). + // Mirrors PushNotificationManager.createReturnNotificationInfo(). + let lfDeviceToken = Observable.shared.loopFollowDeviceToken.value + let lfTeamId = BuildDetails.default.teamID ?? "" + let lfBundleId = Bundle.main.bundleIdentifier ?? "" + let lfProductionEnv = BuildDetails.default.isTestFlightBuild() + + return [ + "nsURL": Storage.shared.url.value, + "nsToken": Storage.shared.token.value, + "dexUsername": Storage.shared.shareUserName.value, + "dexPassword": Storage.shared.sharePassword.value, + "dexServer": Storage.shared.shareServer.value, + "units": Storage.shared.units.value, + "lowLine": Storage.shared.lowLine.value, + "highLine": Storage.shared.highLine.value, + "remoteType": Storage.shared.remoteType.value.rawValue, + "maxBolus": Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()), + "maxCarbs": Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + "trcDeviceToken": Storage.shared.deviceToken.value, + "trcSharedSecret": Storage.shared.sharedSecret.value, + "trcApnsKey": Storage.shared.remoteApnsKey.value, + "trcKeyId": Storage.shared.remoteKeyId.value, + "trcTeamId": Storage.shared.teamId.value ?? "", + "trcBundleId": Storage.shared.bundleId.value, + "trcProductionEnv": Storage.shared.productionEnvironment.value, + "trcUser": Storage.shared.user.value, + "nsWriteAuth": Storage.shared.nsWriteAuth.value, + "lfDeviceToken": lfDeviceToken, + "lfApnsKey": Storage.shared.lfApnsKey.value, + "lfKeyId": Storage.shared.lfKeyId.value, + "lfTeamId": lfTeamId, + "lfBundleId": lfBundleId, + "lfProductionEnv": lfProductionEnv, + "mealWithFatProtein": Storage.shared.mealWithFatProtein.value, + "maxProtein": Storage.shared.maxProtein.value.doubleValue(for: .gram()), + "maxFat": Storage.shared.maxFat.value.doubleValue(for: .gram()), + ] + } + + func sendConfig() { + guard WCSession.default.activationState == .activated else { return } + let config = buildConfig() + try? WCSession.default.updateApplicationContext(config) + + // Also send via message for immediate delivery if Watch is reachable + if WCSession.default.isReachable { + WCSession.default.sendMessage(config, replyHandler: nil, errorHandler: nil) + } + } + + // MARK: - WCSessionDelegate + + func session(_: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error _: Error?) { + if activationState == .activated { + sendConfig() + } + } + + func sessionDidBecomeInactive(_: WCSession) {} + + func sessionDidDeactivate(_: WCSession) { + WCSession.default.activate() + } + + // Re-send config when Watch becomes reachable (handles fresh install) + func sessionReachabilityDidChange(_ session: WCSession) { + if session.isReachable { + sendConfig() + } + } + + // Handle Watch requesting config via applicationContext + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + if applicationContext["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via sendMessage (with reply) + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + if message["requestConfig"] != nil { + let config = buildConfig() + replyHandler(config) + // Also update application context so it's cached + try? WCSession.default.updateApplicationContext(config) + } else { + replyHandler([:]) + } + } + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + if message["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via transferUserInfo + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if userInfo["requestConfig"] != nil { + sendConfig() + } + } +} diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 000000000..8c1c15231 Binary files /dev/null and b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..113d82165 --- /dev/null +++ b/LoopFollowWatch/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/Assets.xcassets/Contents.json b/LoopFollowWatch/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LoopFollowWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollowWatch/BGChartView.swift b/LoopFollowWatch/BGChartView.swift new file mode 100644 index 000000000..ffdf1e126 --- /dev/null +++ b/LoopFollowWatch/BGChartView.swift @@ -0,0 +1,395 @@ +// LoopFollow +// BGChartView.swift + +import Charts +import SwiftUI +import WatchKit + +struct BGChartView: View { + let bgHistory: [BGReading] + let loopStatus: LoopStatus? + let treatments: [Treatment] + let tempTargetEntries: [TempTargetEntry] + let overrideEntries: [OverrideEntry] + let config: WatchConfig + @Binding var timeOffset: Double + @State private var lastHapticOffset: Double = 0 + @Binding var zoomHours: Double + /// Treatment display: 0 = off, 1 = dots only, 2 = dots + labels + @AppStorage("treatmentLevel") private var treatmentLevel: Int = 2 + + private var treatmentFontSize: CGFloat { + switch zoomHours { + case ...0.5: return 10 + case ...1: return 8 + default: return 6 + } + } + + private var treatmentSymbolSize: CGFloat { CGFloat(30.0 * min(1.6, max(0.7, 2.0 / zoomHours))) } + private var showTreatmentLabels: Bool { treatmentLevel >= 2 } + @FocusState private var chartFocused: Bool + + // timeOffset is in units of 5 minutes (1 BG reading), snapped to integers + private var snappedOffset: Double { + timeOffset.rounded() + } + + private var visibleStart: Date { + Date().addingTimeInterval(-zoomHours * 3600 + snappedOffset * 300) + } + + private var visibleEnd: Date { + Date().addingTimeInterval(snappedOffset * 300) + } + + private var centerTime: Date { + visibleStart.addingTimeInterval(visibleEnd.timeIntervalSince(visibleStart) * 0.7) + } + + // Pre-filtered data for visible window only (with small margin) + private var visibleBG: [BGReading] { + let margin: TimeInterval = 600 // 10-min margin + let start = visibleStart.addingTimeInterval(-margin) + let end = visibleEnd.addingTimeInterval(margin) + return bgHistory.filter { $0.timestamp >= start && $0.timestamp <= end } + } + + private var visibleTreatments: [Treatment] { + let margin: TimeInterval = 600 + let start = visibleStart.addingTimeInterval(-margin) + let end = visibleEnd.addingTimeInterval(margin) + return treatments.filter { $0.timestamp >= start && $0.timestamp <= end } + } + + private var visibleOverrides: [OverrideEntry] { + return overrideEntries.filter { $0.endDate >= visibleStart && $0.startDate <= visibleEnd } + } + + private var visibleTempTargets: [TempTargetEntry] { + return tempTargetEntries.filter { $0.endDate >= visibleStart && $0.startDate <= visibleEnd } + } + + private var yDomain: ClosedRange { + if config.units == "mmol/L" { + return 0 ... 16.7 + } + return 0 ... 300 + } + + private func convertBG(_ mgdl: Double) -> Double { + config.units == "mmol/L" ? mgdl * 0.0555 : mgdl + } + + /// Find the closest BG value at a given timestamp, offset slightly above for treatment dots + private func bgValueAbove(timestamp: Date) -> Double { + let closest = visibleBG.min(by: { + abs($0.timestamp.timeIntervalSince(timestamp)) < abs($1.timestamp.timeIntervalSince(timestamp)) + }) + let baseBG: Double + if let closest = closest, abs(closest.timestamp.timeIntervalSince(timestamp)) < 600 { + baseBG = Double(closest.bgValue) + } else { + baseBG = 150 + } + // Offset above by ~15 mg/dL so dots sit above the BG point + return convertBG(baseBG + 15) + } + + /// Find the closest BG value at a given timestamp, offset higher for carb dots to clear bolus markers + private func bgValueAboveCarb(timestamp: Date) -> Double { + let closest = visibleBG.min(by: { + abs($0.timestamp.timeIntervalSince(timestamp)) < abs($1.timestamp.timeIntervalSince(timestamp)) + }) + let baseBG: Double + if let closest = closest, abs(closest.timestamp.timeIntervalSince(timestamp)) < 600 { + baseBG = Double(closest.bgValue) + } else { + baseBG = 150 + } + // Offset above by ~40 mg/dL so carbs clear bolus triangles + their text + return convertBG(baseBG + 75) + } + + var body: some View { + Chart { + if treatmentLevel >= 1 { + // Override ticker tape (purple band at bottom: 0-29 mg/dL) + ForEach(visibleOverrides) { entry in + RectangleMark( + xStart: .value("Start", entry.startDate), + xEnd: .value("End", entry.endDate), + yStart: .value("Low", convertBG(0)), + yEnd: .value("High", convertBG(29)) + ) + .foregroundStyle(.purple.opacity(0.6)) + .annotation(position: .overlay, alignment: .leading) { + Text(entry.name.isEmpty + ? (entry.percentage.map { String(format: "%.0f%%", $0) } ?? "Override") + : entry.name) + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .padding(.leading, 2) + } + } + + // Temp target ticker tape (green band: 31-60 mg/dL) + ForEach(visibleTempTargets) { entry in + RectangleMark( + xStart: .value("Start", entry.startDate), + xEnd: .value("End", entry.endDate), + yStart: .value("Low", convertBG(31)), + yEnd: .value("High", convertBG(60)) + ) + .foregroundStyle(.green.opacity(0.6)) + .annotation(position: .overlay, alignment: .leading) { + Text(entry.reason.isEmpty + ? String(format: "%.0f", entry.targetTop) + : entry.reason) + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .padding(.leading, 2) + } + } + } + + // Midpoint inspection marker + RuleMark(x: .value("Center", centerTime)) + .foregroundStyle(.white.opacity(0.3)) + .lineStyle(StrokeStyle(lineWidth: 0.5)) + + // Prediction lines — detect OpenAPS by checking for populated prediction arrays + if let status = loopStatus { + let hasOpenAPSPredictions = status.ztPredictions != nil || status.iobPredictions != nil || + status.cobPredictions != nil || status.uamPredictions != nil + if status.isOpenAPS || hasOpenAPSPredictions { + predictionMarks(values: status.ztPredictions, start: status.predictionStart, color: Color(red: 0.443, green: 0.380, blue: 0.937), series: "ZT") + predictionMarks(values: status.iobPredictions, start: status.predictionStart, color: Color(red: 0.118, green: 0.588, blue: 0.988), series: "IOB") + predictionMarks(values: status.cobPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.757, blue: 0.271), series: "COB") + predictionMarks(values: status.uamPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.518, blue: 0.271), series: "UAM") + } else { + predictionMarks(values: status.predictions, start: status.predictionStart, color: .purple, series: "Pred") + } + } + + if treatmentLevel >= 1 { + // Bolus dots — blue upside-down triangles, offset above BG + ForEach(visibleTreatments.filter { $0.type == .bolus || $0.type == .smb }) { treatment in + PointMark( + x: .value("Time", treatment.timestamp), + y: .value("BG", bgValueAbove(timestamp: treatment.timestamp)) + ) + .symbol { + Image(systemName: "arrowtriangle.down.fill") + .font(.system(size: treatmentFontSize)) + .foregroundColor(.blue) + } + .symbolSize(treatmentSymbolSize) + .foregroundStyle(.blue) + .annotation(position: .top, spacing: 1) { + if showTreatmentLabels { + Text(String(format: "%g", treatment.value)) + .font(.system(size: treatmentFontSize, weight: .medium)) + .foregroundColor(.white) + } + } + } + + // Carb dots — yellow circles, offset above BG + ForEach(visibleTreatments.filter { $0.type == .carbs }) { treatment in + PointMark( + x: .value("Time", treatment.timestamp), + y: .value("BG", bgValueAboveCarb(timestamp: treatment.timestamp)) + ) + .symbol(.circle) + .symbolSize(treatmentSymbolSize) + .foregroundStyle(.yellow) + .annotation(position: .top, spacing: 1) { + if showTreatmentLabels { + Text("\(Int(treatment.value))") + .font(.system(size: treatmentFontSize, weight: .medium)) + .foregroundColor(.white) + } + } + } + } + } + .chartYScale(domain: yDomain) + .chartXScale(domain: visibleStart ... visibleEnd) + .chartLegend(.hidden) + .chartXAxis { + AxisMarks(values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .abbreviated))) + .font(.system(size: 8)) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: [0, 100, 200, 300].map { convertBG(Double($0)) }) { value in + AxisValueLabel { + if let v = value.as(Double.self) { + Text(config.units == "mmol/L" ? String(format: "%.0f", v) : String(format: "%.0f", v)) + .font(.system(size: 7)) + } + } + } + } + .chartBackground { proxy in + sparklineOverlay(proxy: proxy) + } + .focusable() + .focused($chartFocused) + .digitalCrownRotation($timeOffset, from: -300, through: 22, by: 1, sensitivity: .medium, isContinuous: false, isHapticFeedbackEnabled: false) + .onAppear { chartFocused = true } + .onChange(of: timeOffset) { newValue in + let snapped = newValue.rounded() + if snapped != lastHapticOffset { + lastHapticOffset = snapped + timeOffset = snapped + WKInterfaceDevice.current().play(.click) + } + } + .onTapGesture(count: 5) { + treatmentLevel = (treatmentLevel + 1) % 3 + WKInterfaceDevice.current().play(.click) + } + .onTapGesture(count: 3) { + // Triple-tap: zoom out (reverse cycle) + switch zoomHours { + case 6: zoomHours = 0.25 + case 0.25: zoomHours = 0.5 + case 0.5: zoomHours = 1 + case 1: zoomHours = 2 + case 2: zoomHours = 3 + default: zoomHours = 6 + } + } + .onTapGesture(count: 2) { + // Double-tap: zoom in cycle 6h→3h→2h→1h→30m→15m→6h + switch zoomHours { + case 6: zoomHours = 3 + case 3: zoomHours = 2 + case 2: zoomHours = 1 + case 1: zoomHours = 0.5 + case 0.5: zoomHours = 0.25 + default: zoomHours = 6 + } + } + } + + private func pointColor(bgValue: Int) -> Color { + return bgDynamicColor(Double(bgValue)) + } + + @ChartContentBuilder + private func predictionMarks(values: [Double]?, start: Date?, color: Color, series: String) -> some ChartContent { + if let values = values, let start = start, !values.isEmpty { + ForEach(Array(values.enumerated()), id: \.offset) { index, value in + LineMark( + x: .value("Time", start.addingTimeInterval(Double(index) * 300)), + y: .value("BG", convertBG(value)), + series: .value("Series", series) + ) + .foregroundStyle(color.opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 2])) + .interpolationMethod(.catmullRom) + } + } + } + + // MARK: - BG sparkline overlay (per-segment coloring) + + @ViewBuilder + private func sparklineOverlay(proxy: ChartProxy) -> some View { + // In .chartBackground, positions from proxy are relative to the plot area. + GeometryReader { geo in + let plotH = geo.size.height + let sorted = visibleBG.sorted { $0.timestamp < $1.timestamp } + + Canvas { context, size in + var screenPoints: [(point: CGPoint, bgValue: Int)] = [] + for reading in sorted { + guard let x = proxy.position(forX: reading.timestamp), + let y = proxy.position(forY: convertBG(Double(reading.bgValue))) else { continue } + screenPoints.append((CGPoint(x: x, y: y), reading.bgValue)) + } + + let pts = screenPoints.map(\.point) + guard pts.count >= 2 else { return } + + for i in 0 ..< (pts.count - 1) { + let midBG = Double(screenPoints[i].bgValue + screenPoints[i + 1].bgValue) / 2.0 + let segColor = bgDynamicColor(midBG) + + let fillPath = segmentFillPath(points: pts, index: i, bottomY: size.height) + context.fill( + fillPath, + with: .linearGradient( + Gradient(colors: [segColor.opacity(0.60), segColor.opacity(0.05)]), + startPoint: CGPoint(x: 0, y: 0), + endPoint: CGPoint(x: 0, y: size.height) + ) + ) + + let strokePath = segmentStrokePath(points: pts, index: i) + context.stroke( + strokePath, + with: .color(segColor), + style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round) + ) + } + } + .frame(height: plotH) + } + } + + // MARK: - Catmull-Rom path builders (per-segment coloring) + + /// Single Catmull-Rom curve segment from points[index] to points[index+1]. + private func segmentStrokePath(points: [CGPoint], index i: Int) -> Path { + Path { path in + let p0 = points[max(i - 1, 0)] + let p1 = points[i] + let p2 = points[min(i + 1, points.count - 1)] + let p3 = points[min(i + 2, points.count - 1)] + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) / 6.0, + y: p1.y + (p2.y - p0.y) / 6.0 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) / 6.0, + y: p2.y - (p3.y - p1.y) / 6.0 + ) + + path.move(to: p1) + path.addCurve(to: p2, control1: cp1, control2: cp2) + } + } + + /// Fill area under a single Catmull-Rom segment, closed to bottomY. + private func segmentFillPath(points: [CGPoint], index i: Int, bottomY: CGFloat) -> Path { + Path { path in + let p0 = points[max(i - 1, 0)] + let p1 = points[i] + let p2 = points[min(i + 1, points.count - 1)] + let p3 = points[min(i + 2, points.count - 1)] + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) / 6.0, + y: p1.y + (p2.y - p0.y) / 6.0 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) / 6.0, + y: p2.y - (p3.y - p1.y) / 6.0 + ) + + path.move(to: p1) + path.addCurve(to: p2, control1: cp1, control2: cp2) + path.addLine(to: CGPoint(x: p2.x, y: bottomY)) + path.addLine(to: CGPoint(x: p1.x, y: bottomY)) + path.closeSubpath() + } + } +} diff --git a/LoopFollowWatch/BGDynamicColor.swift b/LoopFollowWatch/BGDynamicColor.swift new file mode 100644 index 000000000..dc9fc02ba --- /dev/null +++ b/LoopFollowWatch/BGDynamicColor.swift @@ -0,0 +1,31 @@ +// LoopFollow +// BGDynamicColor.swift + +import SwiftUI + +/// Returns a hue-based color for a given BG value in mg/dL. +/// Low (≤55) = red, target (100) = green, high (≥220) = purple. +func bgDynamicColor(_ bg: Double) -> Color { + let low = 55.0 + let target = 100.0 + let high = 220.0 + + let redHue: CGFloat = 0.0 / 360.0 + let greenHue: CGFloat = 120.0 / 360.0 + let purpleHue: CGFloat = 270.0 / 360.0 + + let hue: CGFloat + if bg <= low { + hue = redHue + } else if bg >= high { + hue = purpleHue + } else if bg <= target { + let ratio = CGFloat((bg - low) / (target - low)) + hue = redHue + ratio * (greenHue - redHue) + } else { + let ratio = CGFloat((bg - target) / (high - target)) + hue = greenHue + ratio * (purpleHue - greenHue) + } + + return Color(hue: Double(hue), saturation: 0.6, brightness: 0.9) +} diff --git a/LoopFollowWatch/BGFetcher.swift b/LoopFollowWatch/BGFetcher.swift new file mode 100644 index 000000000..e45b20133 --- /dev/null +++ b/LoopFollowWatch/BGFetcher.swift @@ -0,0 +1,1494 @@ +// LoopFollow +// BGFetcher.swift + +import Combine +import Foundation +import WidgetKit + +// MARK: - Widget Data (shared with LoopFollowWidgets target via UserDefaults) + +struct WidgetBGPoint: Codable, Hashable { + let value: Int // mg/dL + let timestamp: Date +} + +struct WidgetData: Codable { + let bgValue: Int + let direction: String + let delta: Int? + let bgTimestamp: Date + let iob: Double? + let cob: Double? + let basalRate: Double? + let scheduledBasal: Double? + let history: [WidgetBGPoint] + let units: String + let updatedAt: Date + + private static let storageKey = "widgetData" + + /// App Group shared between the watch app and widget extension. + static let appGroupID = "group.loopfollow.shared" + + private static var sharedDefaults: UserDefaults { + UserDefaults(suiteName: appGroupID) ?? .standard + } + + func save() { + guard let data = try? JSONEncoder().encode(self) else { return } + Self.sharedDefaults.set(data, forKey: Self.storageKey) + } + + static func load() -> WidgetData? { + guard let data = sharedDefaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) + else { return nil } + return decoded + } +} + +// MARK: - Bolus Calculation + +struct BolusCalculation { + let bg: Double + let target: Double + let isf: Double + let iob: Double + let cob: Double + let pendingCarbs: Double + let cr: Double + let delta: Double + let glucoseEffect: Double + let iobEffect: Double + let cobEffect: Double + let deltaEffect: Double + let fullBolus: Double +} + +class BGFetcher: ObservableObject { + /// Process-wide singleton. Used by both the SwiftUI App body (@StateObject) + /// and the `ExtensionDelegate` background-task handler. Making this a + /// singleton fixes the bug where a cold background launch left the + /// delegate's weak reference nil — the delegate can now reach the fetcher + /// directly without depending on `.onAppear` firing first. + static let shared = BGFetcher() + + @Published var currentBG: BGReading? + @Published var bgHistory: [BGReading] = [] + @Published var loopStatus: LoopStatus? + @Published var overridePresets: [OverridePreset] = [] + @Published var scheduledBasal: Double? + @Published var lastError: String? + @Published var isReloading = false + @Published var activeSource: String = "" // "Nightscout" or "Dexcom" + @Published var statusMatchesScroll: Bool = true + @Published var recommendedBolus: Double = 0 + @Published var bolusCalc: BolusCalculation? + + /// Carbs entered locally on the watch (e.g. from meal screen) not yet in remote COB. + /// Set before navigating to the bolus screen; included in recommended bolus calculation. + var pendingCarbs: Double = 0 + + /// Insulin sent from the watch not yet reflected in remote IOB. + /// Subtracted from the recommended bolus so the user doesn't + /// double-dose while waiting for the next loop cycle. + var pendingInsulin: Double = 0 + + // Treatment data for chart display + @Published var treatments: [Treatment] = [] + @Published var tempTargetEntries: [TempTargetEntry] = [] + @Published var overrideEntries: [OverrideEntry] = [] + + // Profile + device-status detail surfaced in the Follow Status sheet. + @Published private(set) var basalSchedule: [(timeAsSeconds: Double, value: Double)] = [] + @Published private(set) var isfSchedule: [(timeAsSeconds: Double, value: Double)] = [] + @Published private(set) var carbRatioSchedule: [(timeAsSeconds: Double, value: Double)] = [] + @Published private(set) var targetSchedule: [(timeAsSeconds: Double, value: Double)] = [] + @Published private(set) var profileTimezone: TimeZone = .current + @Published private(set) var profileName: String? + @Published private(set) var profileDIA: Double? + @Published private(set) var uploaderBattery: Int? + @Published private(set) var pumpBattery: Int? + @Published private(set) var pumpReservoir: Double? + /// Most-recent active temp basal's `absolute` value from the treatments + /// stream. Mirrors what the iPhone Follow app displays — this is the + /// pump-rounded delivered rate, which can differ from `loopStatus.basalRate` + /// (which comes from `devicestatus.enacted.rate`, the algorithm's request). + @Published private(set) var currentTempBasal: Double? + @Published private(set) var cannulaChangeDate: Date? + @Published private(set) var sensorChangeDate: Date? + @Published private(set) var insulinChangeDate: Date? + @Published private(set) var carbsToday: Double? + + private var timer: Timer? + private var dexSessionToken: String? + private var profileLoaded = false + + private let dexcomUserAgent = "Dexcom Share/3.0.2.11 CFNetwork/711.2.23 Darwin/14.0.0" + private let dexcomApplicationId = "d89443d2-327c-4a6f-89e5-496bbb0317db" + + // MARK: - Adaptive fetch cadence + + // + // CGM readings arrive every ~5 min. Rather than fetching on a fixed 5-min + // repeating timer (which ends up phase-locked to whenever start() was + // called, and so perpetually fetches just before each new reading lands), + // we compute the next fetch time from the last successful reading's + // timestamp: + // + // nextFetch = bg.timestamp + readingInterval + uploadBuffer + // + // uploadBuffer is a small cushion for sensor → phone → Nightscout upload + // latency. If the target is in the past (we're behind, or the next reading + // is late), we clamp to lateReadingPollFloor so we poll at a reasonable + // cadence without tight-looping. The ceiling caps how long we'll wait when + // a sensor reading is genuinely missing. + private static let readingInterval: TimeInterval = 300 + private static let uploadBuffer: TimeInterval = 10 + private static let lateReadingPollFloor: TimeInterval = 30 + private static let readingGapCeiling: TimeInterval = 330 + + /// Compute the delay (seconds from now) until the next fetch, given the + /// timestamp of the most recently known BG reading. Nil bgTimestamp means + /// "no reading yet" — fall back to the ceiling so we retry periodically. + static func nextFetchDelay(afterReadingAt bgTimestamp: Date?, now: Date = Date()) -> TimeInterval { + guard let ts = bgTimestamp else { return readingGapCeiling } + let target = ts.addingTimeInterval(readingInterval + uploadBuffer) + let rawDelay = target.timeIntervalSince(now) + return min(max(rawDelay, lateReadingPollFloor), readingGapCeiling) + } + + func start(config: WatchConfig) { + stop() + profileLoaded = false + fetch(config: config) + // Initial one-shot timer. Rearmed by updateWidgetData() after each + // successful fetch, based on the new reading's timestamp. + scheduleNextFetch(config: config, delay: Self.readingGapCeiling) + } + + func stop() { + timer?.invalidate() + timer = nil + } + + /// Arm a one-shot timer to fire `delay` seconds from now. Replaces any + /// existing scheduled fetch. Safe to call from main only. + private func scheduleNextFetch(config: WatchConfig, delay: TimeInterval) { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + self?.fetch(config: config) + } + } + + /// Re-arm the foreground timer based on the latest reading's timestamp, + /// so the next fetch is scheduled right after the next reading is expected + /// to be available on Nightscout. + private func rearmForegroundTimer(after bgTimestamp: Date) { + guard let config = currentConfig else { return } + let delay = Self.nextFetchDelay(afterReadingAt: bgTimestamp) + scheduleNextFetch(config: config, delay: delay) + } + + func reload() { + // Called by double-tap on freshness text + guard let config = currentConfig else { return } + DispatchQueue.main.async { self.isReloading = true } + fetch(config: config) + } + + private var currentConfig: WatchConfig? + + func fetch(config: WatchConfig) { + currentConfig = config + + // Always fetch from Nightscout if available (BG entries + devicestatus + profile + treatments) + if config.hasNightscoutURL { + fetchNightscout(config: config) + fetchDeviceStatus(config: config) + fetchTreatments(config: config) + if !profileLoaded { + fetchProfile(config: config) + } + } + + // Also try Dexcom if credentials are present and Nightscout is not available + // (if both are available, Nightscout is preferred since it has devicestatus/profile too) + if config.hasDexcomCredentials && !config.hasNightscoutURL { + fetchDexcom(config: config) + } + } + + // MARK: - Nightscout BG Entries + + private func fetchNightscout(config: WatchConfig) { + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/entries.json" + + var queryItems = [URLQueryItem]() + if !config.nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: config.nsToken)) + } + queryItems.append(URLQueryItem(name: "count", value: "300")) + queryItems.append(URLQueryItem(name: "find[type][$ne]", value: "cal")) + components?.queryItems = queryItems + + guard let url = components?.url else { + DispatchQueue.main.async { self.lastError = "Invalid Nightscout URL" } + return + } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 30 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + guard let self = self else { return } + + if let error = error { + DispatchQueue.main.async { self.lastError = error.localizedDescription } + return + } + + guard let data = data else { + DispatchQueue.main.async { self.lastError = "No data received" } + return + } + + self.parseNightscoutResponse(data: data) + }.resume() + } + + private func parseNightscoutResponse(data: Data) { + struct NSEntry: Decodable { + var sgv: Double? + var mbg: Double? + var glucose: Double? + var date: TimeInterval + var direction: String? + + var bgValue: Int? { + if let sgv = sgv { return Int(sgv.rounded()) } + if let mbg = mbg { return Int(mbg.rounded()) } + if let glucose = glucose { return Int(glucose.rounded()) } + return nil + } + } + + do { + let entries = try JSONDecoder().decode([NSEntry].self, from: data) + var readings: [BGReading] = [] + + for (index, entry) in entries.enumerated() { + guard let bgValue = entry.bgValue else { continue } + let timestamp = Date(timeIntervalSince1970: entry.date / 1000) + let direction = entry.direction ?? "" + let delta: Int? + if index + 1 < entries.count, let priorBG = entries[index + 1].bgValue { + delta = bgValue - priorBG + } else { + delta = nil + } + readings.append(BGReading( + bgValue: bgValue, + direction: BGReading.directionArrow(direction), + timestamp: timestamp, + delta: delta + )) + } + + DispatchQueue.main.async { + self.bgHistory = readings + self.currentBG = readings.first + self.isReloading = false + if readings.isEmpty { + self.lastError = "No BG entries" + } else { + self.lastError = nil + self.activeSource = "Nightscout" + self.updateWidgetData() + } + } + } catch { + DispatchQueue.main.async { self.lastError = "Parse error" } + } + } + + private func fallbackToNightscout(config: WatchConfig, dexError: String) { + if config.hasNightscoutURL { + fetchNightscout(config: config) + } else { + DispatchQueue.main.async { self.lastError = dexError } + } + } + + // MARK: - Nightscout Device Status + + func fetchDeviceStatus(config: WatchConfig, completion: (() -> Void)? = nil) { + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/devicestatus.json" + + var queryItems = [URLQueryItem]() + if !config.nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: config.nsToken)) + } + queryItems.append(URLQueryItem(name: "count", value: "1")) + components?.queryItems = queryItems + + guard let url = components?.url else { + completion?() + return + } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 10 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + if let self = self, error == nil, let data = data { + self.parseDeviceStatus(data: data) + } + DispatchQueue.main.async { completion?() } + }.resume() + } + + func fetchDeviceStatusAt(config: WatchConfig, date: Date) { + DispatchQueue.main.async { self.statusMatchesScroll = false } + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/devicestatus.json" + + let formatter = ISO8601DateFormatter() + let dateString = formatter.string(from: date) + + var queryItems = [URLQueryItem]() + if !config.nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: config.nsToken)) + } + queryItems.append(URLQueryItem(name: "count", value: "1")) + queryItems.append(URLQueryItem(name: "find[created_at][$lte]", value: dateString)) + components?.queryItems = queryItems + + guard let url = components?.url else { return } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 15 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + guard let self = self, error == nil, let data = data else { return } + self.parseDeviceStatus(data: data) + }.resume() + } + + private func parseDeviceStatus(data: Data) { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []), + let entries = json as? [[String: Any]], + let lastEntry = entries.first + else { + DispatchQueue.main.async { self.statusMatchesScroll = true } + return + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] + + // Detect Loop vs OpenAPS + if let loopRecord = lastEntry["loop"] as? [String: Any] { + parseLoopDeviceStatus(entry: lastEntry, loopRecord: loopRecord, formatter: formatter) + } else if let openapsRecord = lastEntry["openaps"] as? [String: Any] { + parseOpenAPSDeviceStatus(entry: lastEntry, openapsRecord: openapsRecord, formatter: formatter) + } + } + + /// Pump battery percent. Handles the common shapes: + /// `pump.battery.percent`, `pump.battery` as a number, or + /// `pump.battery.voltage` (skipped — we only surface percent). + private static func parsePumpBattery(entry: [String: Any]) -> Int? { + guard let pump = entry["pump"] as? [String: Any] else { return nil } + if let bDict = pump["battery"] as? [String: Any] { + if let pct = bDict["percent"] as? Double { return Int(pct) } + if let pct = bDict["percent"] as? Int { return pct } + return nil + } + if let pct = pump["battery"] as? Double { return Int(pct) } + if let pct = pump["battery"] as? Int { return pct } + return nil + } + + private func parseLoopDeviceStatus(entry: [String: Any], loopRecord: [String: Any], formatter: ISO8601DateFormatter) { + var iob: Double? + var cob: Double? + var basalRate: Double? + var overrideActive = false + var overrideText: String? + var predictions: [Double]? + var predictionStart: Date? + var recommendedBolus: Double? + + // Pump / uploader info live as siblings of `loop` on the devicestatus entry. + let battery: Int? = { + if let uploader = entry["uploader"] as? [String: Any], + let b = uploader["battery"] as? Double + { + return Int(b) + } + return nil + }() + let pumpBatt = Self.parsePumpBattery(entry: entry) + let reservoir = (entry["pump"] as? [String: Any])?["reservoir"] as? Double + DispatchQueue.main.async { + self.uploaderBattery = battery + self.pumpBattery = pumpBatt + self.pumpReservoir = reservoir + } + + // Timestamp + let timestamp: Date + if let ts = loopRecord["timestamp"] as? String, let d = formatter.date(from: ts) { + timestamp = d + } else { + timestamp = Date() + } + + // IOB + if let iobData = loopRecord["iob"] as? [String: Any], + let iobValue = iobData["iob"] as? Double + { + iob = iobValue + } + + // COB + if let cobData = loopRecord["cob"] as? [String: Any], + let cobValue = cobData["cob"] as? Double + { + cob = cobValue + } + + // Basal + if let enacted = loopRecord["enacted"] as? [String: Any], + let rate = enacted["rate"] as? Double + { + basalRate = rate + } + + // Predictions + if let predictData = loopRecord["predicted"] as? [String: Any], + let values = predictData["values"] as? [Double] + { + predictions = values + predictionStart = timestamp + } + + // Recommended Bolus + if let recBolus = loopRecord["recommendedBolus"] as? Double { + recommendedBolus = recBolus + } + + // Override (top-level in devicestatus for Loop) + if let overrideData = entry["override"] as? [String: Any], + let isActive = overrideData["active"] as? Bool, isActive + { + overrideActive = true + var oText = "" + if let multiplier = overrideData["multiplier"] as? Double { + oText += String(format: "%.0f%%", multiplier * 100) + } else { + oText += "100%" + } + if let correction = overrideData["currentCorrectionRange"] as? [String: Any], + let minVal = correction["minValue"] as? Double, + let maxVal = correction["maxValue"] as? Double + { + oText += " (\(Int(minVal))-\(Int(maxVal)))" + } + overrideText = oText + } + + let status = LoopStatus( + iob: iob, cob: cob, basalRate: basalRate, + overrideActive: overrideActive, overrideText: overrideText, + timestamp: timestamp, + predictions: predictions, predictionStart: predictionStart, + ztPredictions: nil, iobPredictions: nil, + cobPredictions: nil, uamPredictions: nil, + isOpenAPS: false, + tempTargetActive: false, tempTargetText: nil, + recommendedBolus: recommendedBolus, isf: nil, carbRatio: nil, currentTarget: nil, + autosensRatio: nil, eventualBG: nil, tdd: nil, + minPredBG: predictions?.min(), maxPredBG: predictions?.max(), + insulinReq: nil, reason: nil + ) + + DispatchQueue.main.async { + self.loopStatus = status + self.pendingInsulin = 0 + self.statusMatchesScroll = true + self.updateScheduledBasal(for: timestamp) + self.updateRecommendedBolus() + } + } + + private func parseOpenAPSDeviceStatus(entry: [String: Any], openapsRecord: [String: Any], formatter: ISO8601DateFormatter) { + var iob: Double? + var cob: Double? + var basalRate: Double? + var overrideActive = false + var overrideText: String? + var ztPredictions: [Double]? + var iobPredictions: [Double]? + var cobPredictions: [Double]? + var uamPredictions: [Double]? + var predictionStart: Date? + var isf: Double? + var carbRatio: Double? + var currentTarget: Double? + + // Pump / uploader info live as siblings of `openaps` on the devicestatus entry. + let battery: Int? = { + if let uploader = entry["uploader"] as? [String: Any], + let b = uploader["battery"] as? Double + { + return Int(b) + } + return nil + }() + let pumpBatt = Self.parsePumpBattery(entry: entry) + let reservoir = (entry["pump"] as? [String: Any])?["reservoir"] as? Double + DispatchQueue.main.async { + self.uploaderBattery = battery + self.pumpBattery = pumpBatt + self.pumpReservoir = reservoir + } + + let enactedOrSuggested = openapsRecord["suggested"] as? [String: Any] + ?? openapsRecord["enacted"] as? [String: Any] + + // Timestamp + let timestamp: Date + if let ts = enactedOrSuggested?["timestamp"] as? String, let d = formatter.date(from: ts) { + timestamp = d + predictionStart = d + } else { + timestamp = Date() + predictionStart = Date() + } + + // IOB + if let iobData = openapsRecord["iob"] as? [String: Any], + let iobValue = iobData["iob"] as? Double + { + iob = iobValue + } + + // COB - try direct field first, then regex from reason + if let cobValue = enactedOrSuggested?["COB"] as? Double { + cob = cobValue + } else if let reason = enactedOrSuggested?["reason"] as? String { + let pattern = "COB: (\\d+(?:\\.\\d+)?)" + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: reason, range: NSRange(location: 0, length: reason.utf16.count)) + { + let valueString = (reason as NSString).substring(with: match.range(at: 1)) + cob = Double(valueString) + } + } + + // ISF, CR, Target (autosens-adjusted from enacted/suggested) + isf = enactedOrSuggested?["ISF"] as? Double + currentTarget = enactedOrSuggested?["current_target"] as? Double + if let reason = enactedOrSuggested?["reason"] as? String { + let crPattern = "CR: (\\d+(?:\\.\\d+)?)" + if let regex = try? NSRegularExpression(pattern: crPattern), + let match = regex.firstMatch(in: reason, range: NSRange(location: 0, length: reason.utf16.count)) + { + let valueString = (reason as NSString).substring(with: match.range(at: 1)) + carbRatio = Double(valueString) + } + } + + // Basal from enacted + if let enacted = openapsRecord["enacted"] as? [String: Any], + let rate = enacted["rate"] as? Double + { + basalRate = rate + } + + // Predictions - all four types + let predBGsData: [String: Any]? = { + if let suggested = openapsRecord["suggested"] as? [String: Any], + let predBGs = suggested["predBGs"] as? [String: Any] + { + return predBGs + } else if let enacted = openapsRecord["enacted"] as? [String: Any], + let predBGs = enacted["predBGs"] as? [String: Any] + { + return predBGs + } + return nil + }() + + if let predBGs = predBGsData { + ztPredictions = predBGs["ZT"] as? [Double] + iobPredictions = predBGs["IOB"] as? [Double] + cobPredictions = predBGs["COB"] as? [Double] + uamPredictions = predBGs["UAM"] as? [Double] + } + + // Aggregate min/max across all predicted-BG arrays for the Follow Status sheet. + let allPreds = (ztPredictions ?? []) + (iobPredictions ?? []) + (cobPredictions ?? []) + (uamPredictions ?? []) + let minPredBG = allPreds.min() + let maxPredBG = allPreds.max() + + // Extra OpenAPS/Trio fields surfaced in the Follow Status sheet. + let autosensRatio = enactedOrSuggested?["sensitivityRatio"] as? Double + let eventualBG = enactedOrSuggested?["eventualBG"] as? Double + let insulinReq = enactedOrSuggested?["insulinReq"] as? Double + let reasonText = enactedOrSuggested?["reason"] as? String + + // TDD: prefer the explicit field, otherwise regex it out of the reason string + // (matches the iPhone fallback in DeviceStatusOpenAPS.swift). + var tdd: Double? = enactedOrSuggested?["TDD"] as? Double + if tdd == nil, let reason = reasonText { + let pattern = "TDD:\\s*(\\d+(?:\\.\\d+)?)" + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: reason, range: NSRange(location: 0, length: reason.utf16.count)) + { + let valueString = (reason as NSString).substring(with: match.range(at: 1)) + tdd = Double(valueString) + } + } + + // Temp target — only detect from explicit "targetBottom"/"targetTop" in enacted, + // not from the reason string (which always includes "Target:" for the profile target) + var tempTargetActive = false + var tempTargetText: String? + if let enacted = openapsRecord["enacted"] as? [String: Any], + let targetBG = enacted["target_bg"] as? Double, + let currentTarget = enactedOrSuggested?["current_target"] as? Double, + targetBG != currentTarget + { + tempTargetActive = true + tempTargetText = "\(Int(targetBG)) mg/dL" + } + + let status = LoopStatus( + iob: iob, cob: cob, basalRate: basalRate, + overrideActive: overrideActive, overrideText: overrideText, + timestamp: timestamp, + predictions: nil, predictionStart: predictionStart, + ztPredictions: ztPredictions, iobPredictions: iobPredictions, + cobPredictions: cobPredictions, uamPredictions: uamPredictions, + isOpenAPS: true, + tempTargetActive: tempTargetActive, tempTargetText: tempTargetText, + recommendedBolus: nil, isf: isf, carbRatio: carbRatio, currentTarget: currentTarget, + autosensRatio: autosensRatio, eventualBG: eventualBG, tdd: tdd, + minPredBG: minPredBG, maxPredBG: maxPredBG, + insulinReq: insulinReq, reason: reasonText + ) + + DispatchQueue.main.async { + self.loopStatus = status + self.pendingInsulin = 0 + self.statusMatchesScroll = true + self.updateScheduledBasal(for: timestamp) + self.updateRecommendedBolus() + } + } + + // MARK: - Nightscout Profile (Override Presets) + + private func updateScheduledBasal(for date: Date) { + guard !basalSchedule.isEmpty else { return } + var calendar = Calendar.current + calendar.timeZone = profileTimezone + let components = calendar.dateComponents([.hour, .minute, .second], from: date) + let currentSeconds = Double(components.hour ?? 0) * 3600 + Double(components.minute ?? 0) * 60 + Double(components.second ?? 0) + + var scheduled: Double? + for entry in basalSchedule { + if currentSeconds >= entry.timeAsSeconds { + scheduled = entry.value + } + } + // If before first entry, use last entry (wraps around midnight) + if scheduled == nil, let last = basalSchedule.last { + scheduled = last.value + } + + DispatchQueue.main.async { self.scheduledBasal = scheduled } + } + + func updateWidgetData() { + guard let bg = currentBG else { return } + let cutoff = Date().addingTimeInterval(-3.5 * 3600) + let recentHistory = bgHistory.filter { $0.timestamp > cutoff } + let points = recentHistory.map { WidgetBGPoint(value: $0.bgValue, timestamp: $0.timestamp) } + let data = WidgetData( + bgValue: bg.bgValue, + direction: bg.direction, + delta: bg.delta, + bgTimestamp: bg.timestamp, + iob: loopStatus?.iob, + cob: loopStatus?.cob, + basalRate: loopStatus?.basalRate, + scheduledBasal: scheduledBasal, + history: points, + units: currentConfig?.units ?? "mg/dL", + updatedAt: Date() + ) + data.save() + WidgetCenter.shared.reloadTimelines(ofKind: "BGComplication") + + // Re-arm the foreground timer and the background refresh chain based + // on the reading we just wrote, so the next fetch lands right after + // the next reading is expected on Nightscout. + rearmForegroundTimer(after: bg.timestamp) + ExtensionDelegate.scheduleBackgroundRefresh() + } + + func lookupScheduleValue(_ schedule: [(timeAsSeconds: Double, value: Double)]) -> Double? { + guard !schedule.isEmpty else { return nil } + var calendar = Calendar.current + calendar.timeZone = profileTimezone + let components = calendar.dateComponents([.hour, .minute, .second], from: Date()) + let currentSeconds = Double(components.hour ?? 0) * 3600 + Double(components.minute ?? 0) * 60 + Double(components.second ?? 0) + + var result: Double? + for entry in schedule { + if currentSeconds >= entry.timeAsSeconds { + result = entry.value + } + } + return result ?? schedule.last?.value + } + + func updateRecommendedBolus() { + // For Loop: use the pre-calculated recommendedBolus from devicestatus if available + if let recBolus = loopStatus?.recommendedBolus { + recommendedBolus = max(0, recBolus - pendingInsulin) + bolusCalc = nil + return + } + + // For OpenAPS (or fallback): calculate from ISF, CR, target + guard let bg = currentBG?.bgValue else { + recommendedBolus = 0 + bolusCalc = nil + return + } + + // Prefer autosens-adjusted values from devicestatus, fall back to profile schedule + guard let isf = loopStatus?.isf ?? lookupScheduleValue(isfSchedule), isf > 0 else { + recommendedBolus = 0 + bolusCalc = nil + return + } + let cr = loopStatus?.carbRatio ?? lookupScheduleValue(carbRatioSchedule) + let target = loopStatus?.currentTarget ?? lookupScheduleValue(targetSchedule) ?? 100 + let iob = (loopStatus?.iob ?? 0) + pendingInsulin + let cob = loopStatus?.cob ?? 0 + + // Use 15-minute delta: find the BG reading closest to 15 minutes ago + let delta: Double = { + guard let currentTS = currentBG?.timestamp else { return Double(currentBG?.delta ?? 0) } + let target15m = currentTS.addingTimeInterval(-15 * 60) + var closest: BGReading? + var closestDiff = Double.greatestFiniteMagnitude + for reading in bgHistory { + let diff = abs(reading.timestamp.timeIntervalSince(target15m)) + if diff < closestDiff { + closestDiff = diff + closest = reading + } + } + // Only use if within 7.5 minutes of the 15m mark + if let prior = closest, closestDiff < 7.5 * 60 { + return Double(bg - prior.bgValue) + } + return Double(currentBG?.delta ?? 0) + }() + + // Floor-round division results to 0.01 (safety rounding — always rounds down) + let glucoseEffect = floor((Double(bg) - target) / isf * 100) / 100 + let iobEffect = -iob // no rounding — already a concrete value + let totalCarbs = cob + pendingCarbs + let cobEffect = (cr != nil && cr! > 0) ? floor(totalCarbs / cr! * 100) / 100 : 0 + let deltaEffect = floor(delta / isf * 100) / 100 + + let fullBolus = glucoseEffect + iobEffect + cobEffect + deltaEffect + // Round to 2 decimals to match displayed value, then floor to nearest 0.05 + let roundedBolus = (fullBolus * 100).rounded() / 100 + recommendedBolus = max(0, floor(roundedBolus * 20) / 20) + + bolusCalc = BolusCalculation( + bg: Double(bg), target: target, isf: isf, + iob: iob, cob: cob, pendingCarbs: pendingCarbs, + cr: cr ?? 0, delta: delta, + glucoseEffect: glucoseEffect, iobEffect: iobEffect, + cobEffect: cobEffect, deltaEffect: deltaEffect, + fullBolus: fullBolus + ) + } + + private func fetchProfile(config: WatchConfig) { + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/profile/current.json" + + var queryItems = [URLQueryItem]() + if !config.nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: config.nsToken)) + } + components?.queryItems = queryItems + + guard let url = components?.url else { return } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 15 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + guard let self = self, error == nil, let data = data else { return } + self.parseProfile(data: data) + }.resume() + } + + private func parseProfile(data: Data) { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return } + + // Profile can be a single object or an array + let profileDict: [String: Any]? + if let array = json as? [[String: Any]] { + profileDict = array.first + } else if let dict = json as? [String: Any] { + profileDict = dict + } else { + return + } + + guard let profile = profileDict else { return } + + var presets: [OverridePreset] = [] + + // Find the default store + let defaultProfileName = profile["defaultProfile"] as? String ?? "default" + let store = profile["store"] as? [String: Any] + let defaultStore = store?[defaultProfileName] as? [String: Any] + ?? store?["Default"] as? [String: Any] + ?? store?.values.first as? [String: Any] + + // Profile metadata surfaced in the Follow Status sheet + let nameForDisplay = defaultProfileName + let dia = defaultStore?["dia"] as? Double + + // Local copies — assigned to @Published properties on main below. + var newBasalSchedule: [(timeAsSeconds: Double, value: Double)] = basalSchedule + var newISFSchedule: [(timeAsSeconds: Double, value: Double)] = isfSchedule + var newCRSchedule: [(timeAsSeconds: Double, value: Double)] = carbRatioSchedule + var newTargetSchedule: [(timeAsSeconds: Double, value: Double)] = targetSchedule + var newTimezone: TimeZone = profileTimezone + + // Extract basal schedule from default store + if let basalArray = defaultStore?["basal"] as? [[String: Any]] { + var schedule: [(timeAsSeconds: Double, value: Double)] = [] + for entry in basalArray { + guard let value = entry["value"] as? Double else { continue } + let timeAsSeconds = entry["timeAsSeconds"] as? Double ?? 0 + schedule.append((timeAsSeconds: timeAsSeconds, value: value)) + } + schedule.sort { $0.timeAsSeconds < $1.timeAsSeconds } + newBasalSchedule = schedule + } + + // Extract ISF schedule from default store + if let sensArray = defaultStore?["sens"] as? [[String: Any]] { + var schedule: [(timeAsSeconds: Double, value: Double)] = [] + for entry in sensArray { + guard let value = entry["value"] as? Double else { continue } + let timeAsSeconds = entry["timeAsSeconds"] as? Double ?? 0 + schedule.append((timeAsSeconds: timeAsSeconds, value: value)) + } + schedule.sort { $0.timeAsSeconds < $1.timeAsSeconds } + newISFSchedule = schedule + } + + // Extract carb ratio schedule from default store + if let crArray = defaultStore?["carbratio"] as? [[String: Any]] { + var schedule: [(timeAsSeconds: Double, value: Double)] = [] + for entry in crArray { + guard let value = entry["value"] as? Double else { continue } + let timeAsSeconds = entry["timeAsSeconds"] as? Double ?? 0 + schedule.append((timeAsSeconds: timeAsSeconds, value: value)) + } + schedule.sort { $0.timeAsSeconds < $1.timeAsSeconds } + newCRSchedule = schedule + } + + // Extract target BG schedule from default store + if let targetArray = defaultStore?["target_low"] as? [[String: Any]] { + var schedule: [(timeAsSeconds: Double, value: Double)] = [] + for entry in targetArray { + guard let value = entry["value"] as? Double else { continue } + let timeAsSeconds = entry["timeAsSeconds"] as? Double ?? 0 + schedule.append((timeAsSeconds: timeAsSeconds, value: value)) + } + schedule.sort { $0.timeAsSeconds < $1.timeAsSeconds } + newTargetSchedule = schedule + } + + // Extract timezone + if let tz = defaultStore?["timezone"] as? String, + let timezone = TimeZone(identifier: tz) + { + newTimezone = timezone + } + + // Trio overrides — JSON key is "overridePresets" at profile top level + // (NSProfile.swift maps this via CodingKeys: case trioOverrides = "overridePresets") + if let trioOverrides = profile["overridePresets"] as? [[String: Any]] { + for override in trioOverrides { + guard let name = override["name"] as? String else { continue } + let duration = override["duration"] as? Double + let percentage = override["percentage"] as? Double + let target = override["target"] as? Double + presets.append(OverridePreset(name: name, duration: duration, percentage: percentage, target: target)) + } + } + + // Also check inside the default store for overridePresets + if presets.isEmpty, let storeOverrides = defaultStore?["overridePresets"] as? [[String: Any]] { + for override in storeOverrides { + guard let name = override["name"] as? String else { continue } + let duration = override["duration"] as? Double + let percentage = override["percentage"] as? Double + let target = override["target"] as? Double + presets.append(OverridePreset(name: name, duration: duration, percentage: percentage, target: target)) + } + } + + // Loop overrides from loopSettings + if let loopSettings = profile["loopSettings"] as? [String: Any], + let overridePresetsArray = loopSettings["overridePresets"] as? [[String: Any]] + { + for preset in overridePresetsArray { + guard let name = preset["name"] as? String else { continue } + let duration = preset["duration"] as? Double + let scaleFactor = preset["insulinNeedsScaleFactor"] as? Double + let percentage = scaleFactor.map { $0 * 100 } + presets.append(OverridePreset(name: name, duration: duration, percentage: percentage, target: nil)) + } + } + + // Also check loopSettings inside default store + if presets.isEmpty, + let storeLoopSettings = defaultStore?["loopSettings"] as? [String: Any], + let overridePresetsArray = storeLoopSettings["overridePresets"] as? [[String: Any]] + { + for preset in overridePresetsArray { + guard let name = preset["name"] as? String else { continue } + let duration = preset["duration"] as? Double + let scaleFactor = preset["insulinNeedsScaleFactor"] as? Double + let percentage = scaleFactor.map { $0 * 100 } + presets.append(OverridePreset(name: name, duration: duration, percentage: percentage, target: nil)) + } + } + + profileLoaded = true + DispatchQueue.main.async { + self.basalSchedule = newBasalSchedule + self.isfSchedule = newISFSchedule + self.carbRatioSchedule = newCRSchedule + self.targetSchedule = newTargetSchedule + self.profileTimezone = newTimezone + self.overridePresets = presets + self.profileName = nameForDisplay + self.profileDIA = dia + // Update scheduled basal for current time + self.updateScheduledBasal(for: Date()) + self.updateRecommendedBolus() + } + } + + // MARK: - Nightscout Treatments (Bolus, Carbs, Temp Targets, Overrides) + + private func fetchTreatments(config: WatchConfig) { + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/treatments.json" + + let cutoff = Date().addingTimeInterval(-25 * 3600) + let formatter = ISO8601DateFormatter() + + var queryItems = [URLQueryItem]() + if !config.nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: config.nsToken)) + } + queryItems.append(URLQueryItem(name: "find[created_at][$gte]", value: formatter.string(from: cutoff))) + let futureLimit = Date().addingTimeInterval(6 * 3600) + queryItems.append(URLQueryItem(name: "find[created_at][$lte]", value: formatter.string(from: futureLimit))) + components?.queryItems = queryItems + + guard let url = components?.url else { return } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 15 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + guard let self = self, error == nil, let data = data else { return } + self.parseTreatments(data: data) + }.resume() + } + + /// Parse a Nightscout date string, matching the iPhone app's NightscoutUtils.parseDate logic. + /// Handles: "2024-01-01T12:00:00.000Z", "2024-01-01T12:00:00+00:00", "2024-01-01T12:00:00", etc. + private func parseNSDate(_ rawString: String) -> Date? { + var s = rawString + // Strip trailing Z + if s.hasSuffix("Z") { s = String(s.dropLast()) } + // Strip timezone offset like +00:00 or -05:00 + else if let range = s.range(of: "[\\+\\-]\\d{2}:\\d{2}$", options: .regularExpression) { + s.removeSubrange(range) + } + // Strip fractional seconds like .000 or .123456 + s = s.replacingOccurrences(of: "\\.\\d+", with: "", options: .regularExpression) + + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + df.locale = Locale(identifier: "en_US") + df.timeZone = TimeZone(abbreviation: "UTC") + return df.date(from: s) + } + + private func parseTreatments(data: Data) { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []), + let entries = json as? [[String: Any]] + else { return } + + var newTreatments: [Treatment] = [] + var tempTargetRaw: [[String: Any]] = [] + var overrideRaw: [[String: Any]] = [] + var tempBasalRaw: [[String: Any]] = [] + var newCannulaChangeDate: Date? + var newSensorChangeDate: Date? + var newInsulinChangeDate: Date? + + // Step 1: Sort entries into categories, matching iPhone app event types exactly + for entry in entries { + guard let eventType = entry["eventType"] as? String else { continue } + + switch eventType { + case "Pump Site Change", "Site Change": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if newCannulaChangeDate == nil || ts > newCannulaChangeDate! { + newCannulaChangeDate = ts + } + } + + case "Sensor Start", "Sensor Change": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if newSensorChangeDate == nil || ts > newSensorChangeDate! { + newSensorChangeDate = ts + } + } + + case "Insulin Change", "Insulin Cartridge Change": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if newInsulinChangeDate == nil || ts > newInsulinChangeDate! { + newInsulinChangeDate = ts + } + } + + case "Correction Bolus", "Bolus", "External Insulin": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if let automatic = entry["automatic"] as? Bool, automatic { + if let insulin = entry["insulin"] as? Double, insulin > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .smb, value: insulin)) + } + } else { + if let insulin = entry["insulin"] as? Double, insulin > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .bolus, value: insulin)) + } + } + } + + case "SMB": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr), + let insulin = entry["insulin"] as? Double, insulin > 0 + { + newTreatments.append(Treatment(timestamp: ts, type: .smb, value: insulin)) + } + + case "Meal Bolus": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if let insulin = entry["insulin"] as? Double, insulin > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .bolus, value: insulin)) + } + if let carbs = entry["carbs"] as? Double, carbs > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .carbs, value: carbs)) + } + } + + case "Carb Correction": + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr), + let carbs = entry["carbs"] as? Double, carbs > 0 + { + newTreatments.append(Treatment(timestamp: ts, type: .carbs, value: carbs)) + } + + case "Temporary Override", "Exercise": + overrideRaw.append(entry) + + case "Temporary Target": + tempTargetRaw.append(entry) + + case "Temp Basal": + tempBasalRaw.append(entry) + + default: + // Generic bolus/carb fallback + if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr) + { + if let insulin = entry["insulin"] as? Double, insulin > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .bolus, value: insulin)) + } + if let carbs = entry["carbs"] as? Double, carbs > 0 { + newTreatments.append(Treatment(timestamp: ts, type: .carbs, value: carbs)) + } + } + } + } + + // Step 2: Process temp targets (matching iPhone app TemporaryTarget.swift) + var newTempTargets: [TempTargetEntry] = [] + for entry in tempTargetRaw.reversed() { + guard let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let startDate = parseNSDate(dateStr) + else { continue } + + let duration = (entry["duration"] as? Double ?? 5.0) * 60 // seconds + + // duration 0 = cancellation marker: cap the previous active temp target + if duration == 0 { + let cancelTime = startDate.timeIntervalSince1970 + if let idx = newTempTargets.lastIndex(where: { $0.endDate.timeIntervalSince1970 > cancelTime }) { + newTempTargets[idx] = TempTargetEntry( + startDate: newTempTargets[idx].startDate, + endDate: startDate, + targetTop: newTempTargets[idx].targetTop, + targetBottom: newTempTargets[idx].targetBottom, + reason: newTempTargets[idx].reason + ) + } + continue + } + + if duration < 300 { continue } // skip < 5 min + + let low = entry["targetBottom"] as? Double + let high = entry["targetTop"] as? Double + guard let targetValue = low ?? high else { continue } + + let reason = entry["reason"] as? String ?? "" + let endDate = startDate.addingTimeInterval(duration) + newTempTargets.append(TempTargetEntry( + startDate: startDate, + endDate: endDate, + targetTop: high ?? targetValue, + targetBottom: low ?? targetValue, + reason: reason + )) + } + + // Step 3: Process overrides (matching iPhone app Overrides.swift) + let sortedOverrides = overrideRaw.sorted { lhs, rhs in + guard let ls = lhs["timestamp"] as? String ?? lhs["created_at"] as? String, + let rs = rhs["timestamp"] as? String ?? rhs["created_at"] as? String, + let ld = parseNSDate(ls), let rd = parseNSDate(rs) + else { return false } + return ld < rd + } + + var newOverrides: [OverrideEntry] = [] + let now = Date() + let maxEndDate = now.addingTimeInterval(6 * 3600) + + for i in 0 ..< sortedOverrides.count { + let e = sortedOverrides[i] + guard let dateStr = e["timestamp"] as? String ?? e["created_at"] as? String, + let startDate = parseNSDate(dateStr) + else { continue } + + var endDate: Date + if (e["durationType"] as? String) == "indefinite" { + endDate = maxEndDate + } else { + let durationMin = e["duration"] as? Double ?? 5 + endDate = startDate.addingTimeInterval(durationMin * 60) + } + + // Cap at next override start to prevent overlap + if i + 1 < sortedOverrides.count, + let nextDateStr = sortedOverrides[i + 1]["timestamp"] as? String ?? sortedOverrides[i + 1]["created_at"] as? String, + let nextStart = parseNSDate(nextDateStr) + { + if endDate > nextStart.addingTimeInterval(-60) { + endDate = nextStart.addingTimeInterval(-60) + } + } + + if endDate > maxEndDate { endDate = maxEndDate } + if endDate.timeIntervalSince(startDate) < 300 { continue } // skip < 5 min + + let scaleFactor = e["insulinNeedsScaleFactor"] as? Double + let overrideName = e["notes"] as? String ?? e["reason"] as? String ?? "" + newOverrides.append(OverrideEntry( + startDate: startDate, + endDate: endDate, + percentage: scaleFactor.map { $0 * 100 }, + name: overrideName + )) + } + + // Sum of carb treatments whose timestamp is in the device's calendar today. + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today.addingTimeInterval(86400) + let newCarbsToday = newTreatments + .filter { $0.type == .carbs && $0.timestamp >= today && $0.timestamp < tomorrow } + .reduce(0.0) { $0 + $1.value } + + // Find the temp basal that's still running right now and surface its + // `absolute` value. This matches what the iPhone Follow app shows for + // the basal info row — see LoopFollow/Controllers/Nightscout/Treatments/ + // Basals.swift, which uses `absolute` from the latest active Temp Basal + // record. Differs from devicestatus.enacted.rate when the pump rounds. + let nowDate = Date() + let parsedTempBasals: [(start: Date, absolute: Double, duration: Double)] = + tempBasalRaw.compactMap { entry in + guard let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, + let ts = parseNSDate(dateStr), + let absolute = entry["absolute"] as? Double else { return nil } + let duration = entry["duration"] as? Double ?? 0 + return (ts, absolute, duration) + } + .sorted { $0.start < $1.start } + var newCurrentTempBasal: Double? + if let last = parsedTempBasals.last { + let endTime = last.start.addingTimeInterval(last.duration * 60) + if endTime > nowDate { + newCurrentTempBasal = last.absolute + } + } + + DispatchQueue.main.async { + self.treatments = newTreatments + self.tempTargetEntries = newTempTargets + self.overrideEntries = newOverrides + self.cannulaChangeDate = newCannulaChangeDate + self.sensorChangeDate = newSensorChangeDate + self.insulinChangeDate = newInsulinChangeDate + self.carbsToday = newCarbsToday + self.currentTempBasal = newCurrentTempBasal + } + } + + // MARK: - Dexcom Share + + private func fetchDexcom(config: WatchConfig, retryCount: Int = 0) { + if let token = dexSessionToken { + fetchDexcomGlucose(config: config, sessionToken: token, retryCount: retryCount) + } else { + authenticateDexcom(config: config, retryCount: retryCount) + } + } + + private func authenticateDexcom(config: WatchConfig, retryCount: Int) { + let url = URL(string: config.dexServerURL + "/ShareWebServices/Services/General/AuthenticatePublisherAccount")! + let body: [String: Any] = [ + "accountName": config.dexUsername, + "password": config.dexPassword, + "applicationId": dexcomApplicationId, + ] + + dexcomPOST(url: url, body: body) { [weak self] error, response in + guard let self = self else { return } + if let error = error { + self.fallbackToNightscout(config: config, dexError: "Dexcom auth failed: \(error.localizedDescription)") + return + } + + guard let response = response, + let data = response.data(using: .utf8), + let accountId = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? String + else { + self.fallbackToNightscout(config: config, dexError: "Dexcom auth: invalid response") + return + } + + self.loginDexcom(config: config, accountId: accountId, retryCount: retryCount) + } + } + + private func loginDexcom(config: WatchConfig, accountId: String, retryCount: Int) { + let url = URL(string: config.dexServerURL + "/ShareWebServices/Services/General/LoginPublisherAccountById")! + let body: [String: Any] = [ + "accountId": accountId, + "password": config.dexPassword, + "applicationId": dexcomApplicationId, + ] + + dexcomPOST(url: url, body: body) { [weak self] error, response in + guard let self = self else { return } + if let error = error { + self.fallbackToNightscout(config: config, dexError: "Dexcom login failed: \(error.localizedDescription)") + return + } + + guard let response = response, + let data = response.data(using: .utf8), + let token = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? String + else { + self.fallbackToNightscout(config: config, dexError: "Dexcom login: invalid response") + return + } + + self.dexSessionToken = token + self.fetchDexcomGlucose(config: config, sessionToken: token, retryCount: retryCount) + } + } + + private func fetchDexcomGlucose(config: WatchConfig, sessionToken: String, retryCount: Int) { + var components = URLComponents(string: config.dexServerURL + "/ShareWebServices/Services/Publisher/ReadPublisherLatestGlucoseValues")! + components.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionToken), + URLQueryItem(name: "minutes", value: "1500"), + URLQueryItem(name: "maxCount", value: "300"), + ] + + dexcomPOST(url: components.url!, body: nil) { [weak self] error, response in + guard let self = self else { return } + if let error = error { + self.fallbackToNightscout(config: config, dexError: "Dexcom fetch failed: \(error.localizedDescription)") + return + } + + guard let response = response, + let data = response.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data, options: []), + let sgvs = decoded as? [[String: Any]] + else { + if retryCount < 2 { + self.dexSessionToken = nil + self.fetchDexcom(config: config, retryCount: retryCount + 1) + } else { + self.fallbackToNightscout(config: config, dexError: "Dexcom: failed after retries") + } + return + } + + self.parseDexcomResponse(config: config, sgvs: sgvs) + } + } + + private func parseDexcomResponse(config: WatchConfig, sgvs: [[String: Any]]) { + let trendMap = [ + "": 0, "DoubleUp": 1, "SingleUp": 2, "FortyFiveUp": 3, + "Flat": 4, "FortyFiveDown": 5, "SingleDown": 6, "DoubleDown": 7, + "NotComputable": 8, "RateOutOfRange": 9, + ] + + let trendTable = [ + "NONE", "DoubleUp", "SingleUp", "FortyFiveUp", "Flat", + "FortyFiveDown", "SingleDown", "DoubleDown", "NOT COMPUTABLE", "RATE OUT OF RANGE", + ] + + var readings: [BGReading] = [] + + for (index, sgv) in sgvs.enumerated() { + guard let glucose = sgv["Value"] as? Int, + let wt = sgv["WT"] as? String + else { continue } + + let trendIndex: Int + if let trendString = sgv["Trend"] as? String { + trendIndex = trendMap[trendString] ?? 0 + } else if let trendInt = sgv["Trend"] as? Int { + trendIndex = trendInt + } else { + trendIndex = 0 + } + + let direction = trendIndex < trendTable.count ? trendTable[trendIndex] : "NONE" + guard let timestamp = parseDexcomDate(wt) else { continue } + + let delta: Int? + if index + 1 < sgvs.count, let nextGlucose = sgvs[index + 1]["Value"] as? Int { + delta = glucose - nextGlucose + } else { + delta = nil + } + + readings.append(BGReading( + bgValue: glucose, + direction: BGReading.directionArrow(direction), + timestamp: timestamp, + delta: delta + )) + } + + guard !readings.isEmpty else { + fallbackToNightscout(config: config, dexError: "No Dexcom readings") + return + } + + DispatchQueue.main.async { + self.bgHistory = readings + self.currentBG = readings.first + self.isReloading = false + self.lastError = nil + self.activeSource = "Dexcom" + self.updateWidgetData() + } + } + + private func parseDexcomDate(_ wt: String) -> Date? { + guard let range = wt.range(of: "\\((.*)\\)", options: .regularExpression), + let epoch = Double(wt[range].dropFirst().dropLast()) + else { return nil } + return Date(timeIntervalSince1970: epoch / 1000) + } + + private func dexcomPOST(url: URL, body: [String: Any]?, completion: @escaping (Error?, String?) -> Void) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue(dexcomUserAgent, forHTTPHeaderField: "User-Agent") + + if let body = body { + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + } + + URLSession.shared.dataTask(with: request) { data, _, error in + if let error = error { + completion(error, nil) + } else if let data = data { + completion(nil, String(data: data, encoding: .utf8)) + } else { + completion(nil, nil) + } + }.resume() + } +} diff --git a/LoopFollowWatch/BGReading.swift b/LoopFollowWatch/BGReading.swift new file mode 100644 index 000000000..5301da232 --- /dev/null +++ b/LoopFollowWatch/BGReading.swift @@ -0,0 +1,67 @@ +// LoopFollow +// BGReading.swift + +import Foundation +import SwiftUI + +struct BGReading { + let bgValue: Int // raw mg/dL + let direction: String // trend arrow + let timestamp: Date + let delta: Int? // difference from previous reading in mg/dL + + func bgText(units: String) -> String { + if units == "mmol/L" { + let mmol = Double(bgValue) * 0.0555 + return String(format: "%.1f", mmol) + } + return "\(bgValue)" + } + + func deltaText(units: String) -> String { + guard let delta = delta else { return "" } + let prefix = delta >= 0 ? "+" : "" + if units == "mmol/L" { + let mmol = Double(delta) * 0.0555 + return String(format: "%@%.1f", prefix, mmol) + } + return "\(prefix)\(delta)" + } + + func bgColor(lowLine: Double, highLine: Double) -> Color { + let bg = Double(bgValue) + if bg <= lowLine { + return .red + } else if bg >= highLine { + return .yellow + } + return .green + } + + var isStale: Bool { + Date().timeIntervalSince(timestamp) > 720 // 12 minutes + } + + var minAgoText: String { + let totalSeconds = Int(Date().timeIntervalSince(timestamp)) + if totalSeconds < 5 { return "now" } + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return "\(minutes)m \(seconds)s" + } + + static func directionArrow(_ direction: String) -> String { + switch direction { + case "Flat": return "\u{2192}" + case "DoubleUp": return "\u{2191}\u{2191}" + case "SingleUp": return "\u{2191}" + case "FortyFiveUp": return "\u{2197}" + case "FortyFiveDown": return "\u{2198}\u{FE0E}" + case "SingleDown": return "\u{2193}" + case "DoubleDown": return "\u{2193}\u{2193}" + case "NONE", "NOT COMPUTABLE", "RATE OUT OF RANGE", "None", "": + return "-" + default: return "-" + } + } +} diff --git a/LoopFollowWatch/CelebrationOverlay.swift b/LoopFollowWatch/CelebrationOverlay.swift new file mode 100644 index 000000000..5419040dc --- /dev/null +++ b/LoopFollowWatch/CelebrationOverlay.swift @@ -0,0 +1,331 @@ +// LoopFollow +// CelebrationOverlay.swift + +import SwiftUI + +/// Randomly triggered celebration animations on successful remote commands. +/// Appears roughly every 5–15 successful sends as a "surprise and delight" Easter egg. +/// Uses the full watch display for maximum visual impact. +struct CelebrationOverlay: View { + @Binding var isActive: Bool + @State private var animationType: CelebrationType = .confetti + @State private var particles: [Particle] = [] + @State private var phase: Bool = false + @State private var phase2: Bool = false + + enum CelebrationType: CaseIterable { + case confetti, fireworks, sparkleRain, rainbowPulse, partyEmoji + } + + var body: some View { + if isActive { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + ZStack { + switch animationType { + case .confetti: + confettiView(width: w, height: h) + case .fireworks: + fireworksView(width: w, height: h) + case .sparkleRain: + sparkleRainView(width: w, height: h) + case .rainbowPulse: + rainbowPulseView(width: w, height: h) + case .partyEmoji: + partyEmojiView(width: w, height: h) + } + } + .frame(width: w, height: h) + } + .ignoresSafeArea() + .allowsHitTesting(false) + .onAppear { + animationType = CelebrationType.allCases.randomElement() ?? .confetti + phase = false + phase2 = false + generateParticles() + // First wave + withAnimation(.easeOut(duration: 3.5)) { + phase = true + } + // Second wave for some animations + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeOut(duration: 3.0)) { + phase2 = true + } + } + } + } + } + + // MARK: - Randomization + + /// Returns true roughly every 5–15 sends (≈10% chance per send). + static func shouldCelebrate() -> Bool { + return Int.random(in: 1 ... 10) == 1 + } + + /// How long to show the celebration before dismissing (longer than normal 3s). + static let displayDuration: TimeInterval = 5.0 + + // MARK: - Particle Generation + + private struct Particle: Identifiable { + let id = UUID() + let x: Double + let y: Double + let targetX: Double + let targetY: Double + let size: Double + let rotation: Double + let delay: Double + let color: Color + let emoji: String + let wave: Int // 1 or 2 + } + + private func generateParticles() { + switch animationType { + case .confetti: + // Two waves of confetti covering the full screen + let wave1: [Particle] = (0 ..< 40).map { _ in + Particle( + x: 0, y: -20, + targetX: Double.random(in: -120 ... 120), + targetY: Double.random(in: 60 ... 220), + size: Double.random(in: 5 ... 10), + rotation: Double.random(in: 360 ... 1080), + delay: Double.random(in: 0 ... 0.5), + color: [.red, .blue, .green, .yellow, .orange, .pink, .purple, .mint, .cyan].randomElement()!, + emoji: "", wave: 1 + ) + } + let wave2: [Particle] = (0 ..< 25).map { _ in + Particle( + x: Double.random(in: -60 ... 60), y: -40, + targetX: Double.random(in: -120 ... 120), + targetY: Double.random(in: 40 ... 200), + size: Double.random(in: 6 ... 12), + rotation: Double.random(in: 360 ... 1080), + delay: Double.random(in: 0 ... 0.4), + color: [.red, .blue, .green, .yellow, .orange, .pink, .purple, .mint, .cyan].randomElement()!, + emoji: "", wave: 2 + ) + } + particles = wave1 + wave2 + + case .fireworks: + // Multiple burst points across the screen + var all: [Particle] = [] + let bursts: [(Double, Double, Double)] = [ + (0, -30, 0), (-40, -50, 0.3), (35, -20, 0.7), + (-20, 10, 1.5), (30, -45, 1.8), (0, 0, 2.2), + ] + for (bx, by, baseDelay) in bursts { + for _ in 0 ..< 12 { + let angle = Double.random(in: 0 ... (2 * .pi)) + let dist = Double.random(in: 30 ... 90) + all.append(Particle( + x: bx, y: by, + targetX: bx + cos(angle) * dist, + targetY: by + sin(angle) * dist, + size: Double.random(in: 4 ... 8), + rotation: 0, + delay: baseDelay + Double.random(in: 0 ... 0.15), + color: [.red, .orange, .yellow, .cyan, .white, .pink, .green].randomElement()!, + emoji: "", wave: baseDelay < 1.0 ? 1 : 2 + )) + } + } + particles = all + + case .sparkleRain: + // Dense sparkles falling across the full width, two waves + particles = (0 ..< 30).map { i in + let wave = i < 18 ? 1 : 2 + return Particle( + x: Double.random(in: -100 ... 100), + y: -120, + targetX: Double.random(in: -100 ... 100), + targetY: 160, + size: Double.random(in: 12 ... 22), + rotation: Double.random(in: -360 ... 360), + delay: Double.random(in: 0 ... (wave == 1 ? 1.5 : 0.8)), + color: [.yellow, .white, .orange, .cyan, .mint].randomElement()!, + emoji: "", wave: wave + ) + } + + case .rainbowPulse: + // Big rings that fill the entire display, two waves + let colors: [Color] = [.red, .orange, .yellow, .green, .blue, .purple] + let wave1 = colors.enumerated().map { i, color in + Particle( + x: 0, y: 0, targetX: 0, targetY: 0, + size: 300, + rotation: 0, + delay: Double(i) * 0.15, + color: color, emoji: "", wave: 1 + ) + } + let wave2 = colors.reversed().enumerated().map { i, color in + Particle( + x: 0, y: 0, targetX: 0, targetY: 0, + size: 300, + rotation: 0, + delay: Double(i) * 0.15, + color: color, emoji: "", wave: 2 + ) + } + particles = wave1 + wave2 + + case .partyEmoji: + // Huge emoji bouncing in from all edges, two waves + let emojis = ["🎉", "🥳", "🎊", "🪩", "✨", "💫", "⭐️", "🌟", "🎆", "🎇", "🍾", "🥂"] + let wave1: [Particle] = (0 ..< 6).map { _ in + let edge = Int.random(in: 0 ... 3) + let startX: Double + let startY: Double + switch edge { + case 0: startX = Double.random(in: -100 ... 100); startY = -140 + case 1: startX = Double.random(in: -100 ... 100); startY = 140 + case 2: startX = -140; startY = Double.random(in: -80 ... 80) + default: startX = 140; startY = Double.random(in: -80 ... 80) + } + return Particle( + x: startX, y: startY, + targetX: Double.random(in: -50 ... 50), + targetY: Double.random(in: -50 ... 50), + size: Double.random(in: 40 ... 56), + rotation: Double.random(in: -30 ... 30), + delay: Double.random(in: 0 ... 0.6), + color: .white, + emoji: emojis.randomElement()!, + wave: 1 + ) + } + let wave2: [Particle] = (0 ..< 5).map { _ in + let edge = Int.random(in: 0 ... 3) + let startX: Double + let startY: Double + switch edge { + case 0: startX = Double.random(in: -100 ... 100); startY = -140 + case 1: startX = Double.random(in: -100 ... 100); startY = 140 + case 2: startX = -140; startY = Double.random(in: -80 ... 80) + default: startX = 140; startY = Double.random(in: -80 ... 80) + } + return Particle( + x: startX, y: startY, + targetX: Double.random(in: -50 ... 50), + targetY: Double.random(in: -50 ... 50), + size: Double.random(in: 44 ... 60), + rotation: Double.random(in: -30 ... 30), + delay: Double.random(in: 0 ... 0.5), + color: .white, + emoji: emojis.randomElement()!, + wave: 2 + ) + } + particles = wave1 + wave2 + } + } + + // MARK: - Animation Views + + @ViewBuilder + private func confettiView(width: Double, height: Double) -> some View { + ForEach(particles) { p in + let active = p.wave == 1 ? phase : phase2 + RoundedRectangle(cornerRadius: 2) + .fill(p.color) + .frame(width: p.size, height: p.size * 2) + .rotationEffect(.degrees(active ? p.rotation : 0)) + .position( + x: width / 2 + (active ? p.targetX : p.x), + y: height / 2 + (active ? p.targetY : p.y) + ) + .opacity(active ? 0 : 1) + .animation( + .easeOut(duration: 3.0).delay(p.delay), + value: active + ) + } + } + + @ViewBuilder + private func fireworksView(width: Double, height: Double) -> some View { + ForEach(particles) { p in + let active = p.wave == 1 ? phase : phase2 + Circle() + .fill(p.color) + .frame(width: active ? p.size : p.size * 3, height: active ? p.size : p.size * 3) + .shadow(color: p.color, radius: 4) + .position( + x: width / 2 + (active ? p.targetX : p.x), + y: height / 2 + (active ? p.targetY : p.y) + ) + .opacity(active ? 0 : 1) + .animation( + .easeOut(duration: 1.8).delay(p.delay), + value: active + ) + } + } + + @ViewBuilder + private func sparkleRainView(width: Double, height: Double) -> some View { + ForEach(particles) { p in + let active = p.wave == 1 ? phase : phase2 + Image(systemName: "sparkle") + .font(.system(size: p.size, weight: .bold)) + .foregroundColor(p.color) + .shadow(color: p.color, radius: 6) + .rotationEffect(.degrees(active ? p.rotation : 0)) + .position( + x: width / 2 + (active ? p.targetX : p.x), + y: height / 2 + (active ? p.targetY : p.y) + ) + .opacity(active ? 0 : 0.95) + .animation( + .easeIn(duration: 2.5).delay(p.delay), + value: active + ) + } + } + + @ViewBuilder + private func rainbowPulseView(width: Double, height: Double) -> some View { + ForEach(particles) { p in + let active = p.wave == 1 ? phase : phase2 + Circle() + .stroke(p.color, lineWidth: 6) + .frame(width: active ? p.size : 0, height: active ? p.size : 0) + .position(x: width / 2, y: height / 2) + .opacity(active ? 0 : 0.9) + .animation( + .easeOut(duration: 2.5).delay(p.delay), + value: active + ) + } + } + + @ViewBuilder + private func partyEmojiView(width: Double, height: Double) -> some View { + ForEach(particles) { p in + let active = p.wave == 1 ? phase : phase2 + Text(p.emoji) + .font(.system(size: p.size)) + .rotationEffect(.degrees(active ? p.rotation : 0)) + .position( + x: width / 2 + (active ? p.targetX : p.x), + y: height / 2 + (active ? p.targetY : p.y) + ) + .scaleEffect(active ? 1.2 : 0.1) + .animation( + .spring(response: 0.6, dampingFraction: 0.55).delay(p.delay), + value: active + ) + } + } +} diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift new file mode 100644 index 000000000..f9b689904 --- /dev/null +++ b/LoopFollowWatch/ContentView.swift @@ -0,0 +1,380 @@ +// LoopFollow +// ContentView.swift + +import SwiftUI +import WatchKit +import WidgetKit + +struct ContentView: View { + @ObservedObject var sessionManager: WatchSessionManager + @ObservedObject var bgFetcher: BGFetcher + + @State private var now = Date() + @State private var timeOffset: Double = 7.2 // zoomHours(2) * 3.6 — aligns marker with "now" + @State private var zoomHours: Double = 2 + @State private var showReloadCheck = false + @State private var showLoopDetail = false + @State private var timeTravelDebounce: Timer? + @Environment(\.scenePhase) private var scenePhase + let secondTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + /// Whether the user has scrolled away from the present (more than 1 reading back) + private var isTimeTravel: Bool { timeOffset < -1 } + + /// The inspected point — 70% through the visible chart window + private var viewCenterTime: Date { + Date().addingTimeInterval(timeOffset * 300 - zoomHours * 3600 * 0.3) + } + + var body: some View { + Group { + if let config = sessionManager.config, config.hasAnySource { + if let reading = displayReading { + mainView(reading: reading, config: config) + } else if let error = bgFetcher.lastError { + VStack(spacing: 4) { + Text("---") + .font(.system(size: 44, weight: .bold, design: .rounded)) + .foregroundColor(.gray) + Text(error) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } else { + VStack(spacing: 8) { + ProgressView() + Text("Loading...") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .offset(y: -20) + } + } + } else { + VStack(spacing: 8) { + Text("No Config") + .font(.headline) + .foregroundColor(.secondary) + Text("Open LoopFollow on\nyour iPhone to sync\nsettings.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + } + .onReceive(secondTimer) { _ in now = Date() } + .onChange(of: zoomHours) { newZoom in + // Re-align inspection marker to "now" when zoom changes + timeOffset = newZoom * 3.6 + } + .onChange(of: timeOffset) { _ in + timeTravelDebounce?.invalidate() + if isTimeTravel, let config = sessionManager.config { + timeTravelDebounce = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in + bgFetcher.fetchDeviceStatusAt(config: config, date: viewCenterTime) + } + } + } + .onChange(of: scenePhase) { newPhase in + if newPhase == .inactive { + WidgetCenter.shared.reloadTimelines(ofKind: "BGComplication") + } + if newPhase == .active { + refreshIfStale() + } + } + } + + /// Refresh data if the last BG reading is older than 5 minutes + private func refreshIfStale() { + guard let reading = bgFetcher.currentBG else { + bgFetcher.reload() + return + } + if Date().timeIntervalSince(reading.timestamp) > 300 { + bgFetcher.reload() + } + } + + /// Visible chart window edges (mirrors BGChartView's calculation) + private var visibleStart: Date { + Date().addingTimeInterval(-zoomHours * 3600 + timeOffset.rounded() * 300) + } + + private var visibleEnd: Date { + Date().addingTimeInterval(timeOffset.rounded() * 300) + } + + private func bgBarGradient(bgHistory: [BGReading]) -> LinearGradient { + let start = visibleStart + let end = visibleEnd + let visible = bgHistory.filter { $0.timestamp >= start && $0.timestamp <= end } + .sorted { $0.timestamp < $1.timestamp } + guard visible.count >= 2, + let first = visible.first?.timestamp, + let last = visible.last?.timestamp, + last > first + else { + // Fall back to single color from current reading + if let reading = visible.first ?? bgHistory.last { + return LinearGradient(colors: [bgDynamicColor(Double(reading.bgValue)).opacity(0.4)], startPoint: .leading, endPoint: .trailing) + } + return LinearGradient(colors: [bgDynamicColor(100).opacity(0.4)], startPoint: .leading, endPoint: .trailing) + } + let span = last.timeIntervalSince(first) + let step = max(1, visible.count / 10) + var stops: [Gradient.Stop] = [] + for i in stride(from: 0, to: visible.count, by: step) { + let t = visible[i].timestamp.timeIntervalSince(first) / span + stops.append(.init(color: bgDynamicColor(Double(visible[i].bgValue)).opacity(0.4), location: t)) + } + if let lastReading = visible.last { + stops.append(.init(color: bgDynamicColor(Double(lastReading.bgValue)).opacity(0.4), location: 1.0)) + } + return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing) + } + + private var displayReading: BGReading? { + bgFetcher.bgHistory.min(by: { + abs($0.timestamp.timeIntervalSince(viewCenterTime)) < abs($1.timestamp.timeIntervalSince(viewCenterTime)) + }) ?? bgFetcher.currentBG + } + + @ViewBuilder + private func mainView(reading: BGReading, config: WatchConfig) -> some View { + let bgColor = bgDynamicColor(Double(reading.bgValue)) + let stale = isTimeTravel ? false : reading.isStale + + ZStack { + VStack(spacing: 0) { + // Row 1: BG + trend/delta stack ... loop indicator + reload + HStack(alignment: .center, spacing: 2) { + Text(reading.bgText(units: config.units)) + .font(.system(size: 48, weight: .regular, design: .default)) + .foregroundColor(bgColor) + .lineLimit(1) + .fixedSize() + + VStack(alignment: .center, spacing: -3) { + Text(reading.direction) + .font(.system(size: 22, weight: .semibold, design: .default)) + .foregroundColor(.white) + + if !reading.deltaText(units: config.units).isEmpty { + Text(reading.deltaText(units: config.units)) + .font(.system(size: 20, weight: .medium, design: .default)) + .foregroundColor(.white) + .lineLimit(1) + .offset(y: -2) + } + } + .fixedSize() + + Spacer() + + // Loop success indicator + Button { + showLoopDetail = true + } label: { + Image(systemName: loopStatusIcon) + .font(.system(size: 27, weight: .medium)) + .foregroundColor(loopStatusColor) + } + .buttonStyle(.plain) + .padding(.trailing, 12) + .sheet(isPresented: $showLoopDetail) { + FollowStatusView(bgFetcher: bgFetcher, sessionManager: sessionManager) + } + + // Reload button + Button { + timeOffset = zoomHours * 3.6 + bgFetcher.reload() + } label: { + Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") + .font(.system(size: 26, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 4) + .padding(.top, 30) + + // Row 2: Gray bar — IOB (left), COB (center), Basal (right) + HStack(spacing: 0) { + if let status = displayStatus { + let dataColor: Color = isTimeTravel && !bgFetcher.statusMatchesScroll ? .gray : .white + if let iob = status.iob { + Text(String(format: "%.1fU", iob)) + .foregroundColor(dataColor) + } + Spacer() + if let cob = status.cob { + Text(String(format: "%.0fg", cob)) + .foregroundColor(dataColor) + } + Spacer() + if let currentBasal = status.basalRate { + let scheduled = bgFetcher.scheduledBasal ?? currentBasal + Text(String(format: "%.1f\u{2192}%.1fU/h", scheduled, currentBasal)) + .foregroundColor(dataColor) + } + } + } + .font(.system(size: 16, weight: .medium, design: .default)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.5) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + bgBarGradient(bgHistory: bgFetcher.bgHistory) + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white, location: 0.06), + .init(color: .white, location: 0.94), + .init(color: .clear, location: 1.0), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .mask( + LinearGradient( + stops: [ + .init(color: .white.opacity(0.3), location: 0), + .init(color: .white, location: 0.45), + .init(color: .white, location: 0.55), + .init(color: .white.opacity(0.3), location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + + // Spacer so chart y-axis "300" label doesn't overlap gray bar + Spacer().frame(height: 6) + + // Row 3: Chart — takes all remaining space + BGChartView( + bgHistory: bgFetcher.bgHistory, + loopStatus: bgFetcher.loopStatus, + treatments: bgFetcher.treatments, + tempTargetEntries: bgFetcher.tempTargetEntries, + overrideEntries: bgFetcher.overrideEntries, + config: config, + timeOffset: $timeOffset, + zoomHours: $zoomHours + ) + .frame(maxHeight: .infinity) + + // Row 4: Status + source combined in one row + HStack(spacing: 4) { + Circle() + .fill(bgFetcher.lastError == nil ? Color.green : Color.red) + .frame(width: 6, height: 6) + Text(freshnessText(reading: reading)) + .foregroundColor(isTimeTravel ? .blue : .white) + Text("·") + .foregroundColor(.secondary) + Text(bgFetcher.activeSource.isEmpty ? "---" : bgFetcher.activeSource) + .foregroundColor(.secondary) + } + .font(.system(size: 13)) + .lineLimit(1) + + // Footer: Override and/or Temp Target (only when active) + if let status = displayStatus { + if status.overrideActive, let text = status.overrideText { + Text("Override: \(text)") + .font(.system(size: 10)) + .foregroundColor(.purple) + } + if status.tempTargetActive, let text = status.tempTargetText { + Text("Temp Target: \(text)") + .font(.system(size: 10)) + .foregroundColor(.orange) + } + } + } + .padding(.bottom, 10) + .opacity(stale ? 0.6 : 1.0) + + // Reload overlay + if bgFetcher.isReloading { + reloadOverlay(success: false) + } else if showReloadCheck { + reloadOverlay(success: true) + } + } + .onChange(of: bgFetcher.isReloading) { newValue in + if !newValue { + showReloadCheck = true + WKInterfaceDevice.current().play(.success) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + showReloadCheck = false + } + } + } + } + + private var displayStatus: LoopStatus? { + bgFetcher.loopStatus + } + + /// Whether Trio looped successfully on the most recent BG reading. + /// Compares loop status timestamp to latest BG — if within 6 minutes, it looped. + private var loopedOnLatestReading: Bool { + guard let loopTime = bgFetcher.loopStatus?.timestamp, + let bgTime = bgFetcher.currentBG?.timestamp else { return false } + return abs(loopTime.timeIntervalSince(bgTime)) < 360 + } + + private var loopStatusIcon: String { + loopedOnLatestReading ? "circle.circle.fill" : "circle.dashed" + } + + private var loopStatusColor: Color { + guard bgFetcher.loopStatus != nil else { return .gray } + return loopedOnLatestReading ? .green : .orange + } + + @ViewBuilder + private func reloadOverlay(success: Bool) -> some View { + ZStack { + Color.black.opacity(0.6) + .cornerRadius(16) + .frame(width: 80, height: 80) + + if success { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 36)) + .foregroundColor(.green) + } else { + ProgressView() + .scaleEffect(1.5) + .tint(.white) + } + } + } + + private func freshnessText(reading: BGReading) -> String { + // When not showing the latest reading, display the clock time + if let latest = bgFetcher.currentBG, + reading.timestamp != latest.timestamp + { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter.string(from: reading.timestamp) + } + // At current reading: live countdown + let totalSeconds = Int(now.timeIntervalSince(reading.timestamp)) + if totalSeconds < 5 { return "now" } + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return "\(minutes)m \(seconds)s" + } +} diff --git a/LoopFollowWatch/CrownCaptureView.swift b/LoopFollowWatch/CrownCaptureView.swift new file mode 100644 index 000000000..3d62c92d1 --- /dev/null +++ b/LoopFollowWatch/CrownCaptureView.swift @@ -0,0 +1,34 @@ +// LoopFollow +// CrownCaptureView.swift + +import SwiftUI + +/// An invisible view that captures Digital Crown input without interfering +/// with the parent ScrollView. When this view is present (via overlay), +/// it grabs focus and routes crown rotation to the provided binding. +/// When removed from the hierarchy, the ScrollView regains crown control. +struct CrownCaptureView: View { + @Binding var value: Double + let from: Double + let through: Double + let by: Double + let sensitivity: DigitalCrownRotationalSensitivity + @FocusState private var isFocused: Bool + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .focusable() + .focused($isFocused) + .digitalCrownRotation( + $value, + from: from, + through: through, + by: by, + sensitivity: sensitivity, + isContinuous: false, + isHapticFeedbackEnabled: false + ) + .onAppear { isFocused = true } + } +} diff --git a/LoopFollowWatch/CrownConfirmView.swift b/LoopFollowWatch/CrownConfirmView.swift new file mode 100644 index 000000000..201be7779 --- /dev/null +++ b/LoopFollowWatch/CrownConfirmView.swift @@ -0,0 +1,121 @@ +// LoopFollow +// CrownConfirmView.swift + +import SwiftUI +import WatchKit + +/// Reusable crown-rotation confirmation component. +/// User must TAP the wheel icon once, then scroll the Digital Crown to confirm. +struct CrownConfirmView: View { + let label: String + let onConfirm: () -> Void + + @State private var tapped = false + @State private var progress: Double = 0 + @State private var confirmed = false + @State private var resetTimer: Timer? + + // A full crown rotation is roughly 1.0 in value + private let fullRotation: Double = 1.0 + + var body: some View { + VStack(spacing: 6) { + ZStack { + // Background ring + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 6) + + // Progress ring (only visible after tap) + if tapped { + Circle() + .trim(from: 0, to: min(progress / fullRotation, 1.0)) + .stroke( + confirmed ? Color.green : Color.blue, + style: StrokeStyle(lineWidth: 6, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.15), value: progress) + } + + // Center content + if confirmed { + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.green) + } else { + VStack(spacing: 2) { + Image(systemName: "digitalcrown.arrow.clockwise") + .font(.system(size: tapped ? 20 : 24)) + .foregroundColor(tapped ? .blue : .gray.opacity(0.5)) + .rotationEffect(.degrees(tapped ? progress / fullRotation * 360 : 0)) + Text(tapped ? "Scroll" : "Tap") + .font(.system(size: 10)) + .foregroundColor(tapped ? .blue : .gray.opacity(0.5)) + } + } + } + .frame(width: 80, height: 80) + .padding(.horizontal, 8) + .onTapGesture { + if !tapped && !confirmed { + withAnimation(.none) { tapped = true } + WKInterfaceDevice.current().play(.click) + } + } + + // Instruction text — fixed height to prevent layout shifts + Group { + if confirmed { + Text("Sent!") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.green) + } else if tapped { + Text("Scroll crown \(label)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + } else { + Text("Tap wheel \(label)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + } + } + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(height: 16) + .multilineTextAlignment(.center) + } + .focusable() + .digitalCrownRotation( + $progress, + from: 0, + through: fullRotation, + by: 0.02, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: true + ) + .onChange(of: progress) { newValue in + guard tapped else { + progress = 0 + return + } + + // Reset inactivity timer + resetTimer?.invalidate() + resetTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + if !confirmed { + withAnimation { progress = 0 } + } + } + + // Check for completion + if newValue >= fullRotation, !confirmed { + withAnimation { + confirmed = true + } + WKInterfaceDevice.current().play(.success) + onConfirm() + } + } + } +} diff --git a/LoopFollowWatch/CrownRotationModifier.swift b/LoopFollowWatch/CrownRotationModifier.swift new file mode 100644 index 000000000..a2edd678f --- /dev/null +++ b/LoopFollowWatch/CrownRotationModifier.swift @@ -0,0 +1,38 @@ +// LoopFollow +// CrownRotationModifier.swift + +import SwiftUI + +/// Conditionally applies `.focusable()` and `.digitalCrownRotation()` together, +/// avoiding the "Crown Sequencer was set up without a view property" warning +/// that occurs when `.digitalCrownRotation()` is attached to a non-focusable view. +/// Automatically requests focus on appear so the crown works immediately. +struct CrownRotationModifier: ViewModifier { + let isActive: Bool + @Binding var value: Double + let from: Double + let through: Double + let by: Double + let sensitivity: DigitalCrownRotationalSensitivity + @FocusState private var isFocused: Bool + + func body(content: Content) -> some View { + if isActive { + content + .focusable() + .focused($isFocused) + .digitalCrownRotation( + $value, + from: from, + through: through, + by: by, + sensitivity: sensitivity, + isContinuous: false, + isHapticFeedbackEnabled: false + ) + .onAppear { isFocused = true } + } else { + content + } + } +} diff --git a/LoopFollowWatch/FollowStatusView.swift b/LoopFollowWatch/FollowStatusView.swift new file mode 100644 index 000000000..37b8402a6 --- /dev/null +++ b/LoopFollowWatch/FollowStatusView.swift @@ -0,0 +1,457 @@ +// LoopFollow +// FollowStatusView.swift + +import SwiftUI + +struct FollowStatusView: View { + @ObservedObject var bgFetcher: BGFetcher + @ObservedObject var sessionManager: WatchSessionManager + + @State private var selectedTab = 0 + + var body: some View { + VStack(spacing: 4) { + header + tabSelector + + ScrollView { + VStack(alignment: .leading, spacing: 8) { + if selectedTab == 0 { + DeviceStatusTab(bgFetcher: bgFetcher, sessionManager: sessionManager) + } else { + ProfileTab(bgFetcher: bgFetcher, sessionManager: sessionManager) + } + } + .padding(.horizontal, 6) + .padding(.bottom, 12) + } + .id(selectedTab) + } + } + + /// Two-button segmented selector — watchOS doesn't support `.segmented` + /// Picker style, so we render this ourselves. + private var tabSelector: some View { + HStack(spacing: 4) { + tabButton(title: "Device", tag: 0) + tabButton(title: "Profile", tag: 1) + } + .padding(.horizontal, 6) + } + + private func tabButton(title: String, tag: Int) -> some View { + Button { + selectedTab = tag + } label: { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(selectedTab == tag ? .black : .white) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background(selectedTab == tag ? Color.white : Color.white.opacity(0.15)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + private var header: some View { + HStack(spacing: 4) { + Circle() + .fill(bgFetcher.lastError == nil ? Color.green : Color.red) + .frame(width: 5, height: 5) + Text(bgFetcher.activeSource.isEmpty ? "—" : bgFetcher.activeSource) + .foregroundColor(.secondary) + if let ts = bgFetcher.loopStatus?.timestamp { + Text("·") + .foregroundColor(.secondary) + Text(FollowStatusFormat.relative(ts)) + .foregroundColor(.secondary) + } + Spacer() + } + .font(.system(size: 11)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.top, 2) + } +} + +// MARK: - Device Status tab + +private struct DeviceStatusTab: View { + @ObservedObject var bgFetcher: BGFetcher + @ObservedObject var sessionManager: WatchSessionManager + + private var units: String { + sessionManager.config?.units ?? "mg/dL" + } + + var body: some View { + updatedSection + loopSection + overrideSection + devicesSection + todaySection + reasonSection + } + + private var loopSection: some View { + let s = bgFetcher.loopStatus + let target = s?.currentTarget ?? bgFetcher.lookupScheduleValue(bgFetcher.targetSchedule) + // Loop: direct recommendedBolus from devicestatus. + // OpenAPS: bgFetcher computes one in updateRecommendedBolus(). + let recBolus: Double? = s?.recommendedBolus + ?? (bgFetcher.recommendedBolus > 0 ? bgFetcher.recommendedBolus : nil) + + return VStack(alignment: .leading, spacing: 4) { + SectionHeader("Loop") + Group { + StatusRow("IOB", FollowStatusFormat.units(s?.iob, decimals: 2, suffix: " U")) + StatusRow("COB", FollowStatusFormat.units(s?.cob, decimals: 0, suffix: " g")) + // Prefer the temp-basal treatment's `absolute` (what the pump + // actually delivered, matching the iPhone Follow display) over + // devicestatus.enacted.rate (the algorithm's request, which can + // round differently). + StatusRow("Basal", FollowStatusFormat.currentVsScheduled( + current: bgFetcher.currentTempBasal ?? s?.basalRate, + scheduled: bgFetcher.scheduledBasal, + valueFormatter: { String(format: "%.2f", $0) }, + suffix: " U/hr" + )) + if s?.isOpenAPS == true { + StatusRow("ISF", FollowStatusFormat.currentVsScheduled( + current: s?.isf, + scheduled: bgFetcher.lookupScheduleValue(bgFetcher.isfSchedule), + valueFormatter: { FollowStatusFormat.bgLike($0, units: units) ?? "—" } + )) + StatusRow("CR", FollowStatusFormat.currentVsScheduled( + current: s?.carbRatio, + scheduled: bgFetcher.lookupScheduleValue(bgFetcher.carbRatioSchedule), + valueFormatter: { String(format: "%.1f", $0) }, + suffix: " g/U" + )) + } + } + Group { + StatusRow("Target", FollowStatusFormat.bgLike(target, units: units, withUnits: true)) + if s?.isOpenAPS == true { + StatusRow("Eventual BG", FollowStatusFormat.bgLike(s?.eventualBG, units: units, withUnits: true)) + if let mn = s?.minPredBG, let mx = s?.maxPredBG { + StatusRow("Min/Max", "\(FollowStatusFormat.bgLike(mn, units: units) ?? "—")/\(FollowStatusFormat.bgLike(mx, units: units) ?? "—")") + } + if let auto = s?.autosensRatio { + StatusRow("Autosens", String(format: "%.0f%%", auto * 100)) + } + } + if recBolus != nil { + StatusRow("Rec. Bolus", FollowStatusFormat.units(recBolus, decimals: 2, suffix: " U")) + } + if s?.isOpenAPS == true, let req = s?.insulinReq { + StatusRow("Req. Insulin", String(format: "%.2f U", req)) + } + } + } + } + + @ViewBuilder + private var overrideSection: some View { + let s = bgFetcher.loopStatus + if (s?.overrideActive == true && s?.overrideText != nil) + || (s?.tempTargetActive == true && s?.tempTargetText != nil) + { + VStack(alignment: .leading, spacing: 4) { + SectionHeader("Override") + if let oText = s?.overrideText, s?.overrideActive == true { + StatusRow("Override", oText) + } + if let tText = s?.tempTargetText, s?.tempTargetActive == true { + StatusRow("Temp Target", tText) + } + } + } + } + + @ViewBuilder + private var reasonSection: some View { + if let reason = bgFetcher.loopStatus?.reason, !reason.isEmpty { + VStack(alignment: .leading, spacing: 4) { + SectionHeader("Reason") + Text(FollowStatusFormat.formatReason(reason)) + .font(.system(size: 12)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private var devicesSection: some View { + let hasAny = bgFetcher.pumpBattery != nil + || bgFetcher.uploaderBattery != nil + || bgFetcher.cannulaChangeDate != nil + || bgFetcher.sensorChangeDate != nil + || bgFetcher.insulinChangeDate != nil + || bgFetcher.pumpReservoir != nil + if hasAny { + VStack(alignment: .leading, spacing: 4) { + SectionHeader("Devices") + if let pb = bgFetcher.pumpBattery { + StatusRow("Pump Battery", "\(pb)%") + } + if let tb = bgFetcher.uploaderBattery { + StatusRow("Trio Battery", "\(tb)%") + } + if let d = bgFetcher.cannulaChangeDate { + StatusRow("Cannula (CAGE)", FollowStatusFormat.age(d)) + } + if let d = bgFetcher.sensorChangeDate { + StatusRow("Sensor (SAGE)", FollowStatusFormat.age(d)) + } + if let d = bgFetcher.insulinChangeDate { + StatusRow("Insulin (IAGE)", FollowStatusFormat.age(d)) + } + if let reservoir = bgFetcher.pumpReservoir { + StatusRow("Reservoir", String(format: "%.0f U", reservoir)) + } + } + } + } + + @ViewBuilder + private var todaySection: some View { + let tdd = bgFetcher.loopStatus?.tdd + if bgFetcher.carbsToday != nil || tdd != nil { + VStack(alignment: .leading, spacing: 4) { + SectionHeader("Today") + if let carbs = bgFetcher.carbsToday { + StatusRow("Carbs", String(format: "%.0f g", carbs)) + } + if let tdd = tdd { + StatusRow("TDD", String(format: "%.1f U", tdd)) + } + } + } + } + + private var updatedSection: some View { + VStack(alignment: .leading, spacing: 4) { + SectionHeader("Updated") + if let ts = bgFetcher.loopStatus?.timestamp { + StatusRow("Last loop", "\(FollowStatusFormat.clock(ts)) (\(FollowStatusFormat.relative(ts)))") + } + StatusRow("Source", bgFetcher.activeSource.isEmpty ? "—" : bgFetcher.activeSource) + } + } +} + +// MARK: - Profile tab + +private struct ProfileTab: View { + @ObservedObject var bgFetcher: BGFetcher + @ObservedObject var sessionManager: WatchSessionManager + + private var units: String { + sessionManager.config?.units ?? "mg/dL" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + scheduleSection(title: "Basal Rates", schedule: bgFetcher.basalSchedule) { value in + String(format: "%.2f U/hr", value) + } + scheduleSection(title: "ISF", schedule: bgFetcher.isfSchedule) { value in + FollowStatusFormat.bgLike(value, units: units) ?? "—" + } + scheduleSection(title: "Carb Ratios", schedule: bgFetcher.carbRatioSchedule) { value in + String(format: "%.1f g/U", value) + } + scheduleSection(title: "Targets", schedule: bgFetcher.targetSchedule) { value in + FollowStatusFormat.bgLike(value, units: units, withUnits: true) ?? "—" + } + } + } + + @ViewBuilder + private func scheduleSection( + title: String, + schedule: [(timeAsSeconds: Double, value: Double)], + formatter: @escaping (Double) -> String + ) -> some View { + if !schedule.isEmpty { + VStack(alignment: .leading, spacing: 4) { + SectionHeader(title) + ForEach(schedule.indices, id: \.self) { i in + let entry = schedule[i] + StatusRow( + FollowStatusFormat.timeOfDay(entry.timeAsSeconds, timezone: bgFetcher.profileTimezone), + formatter(entry.value) + ) + } + } + } + } +} + +// MARK: - Row primitives + +private struct StatusRow: View { + let label: String + let value: String? + + init(_ label: String, _ value: String?) { + self.label = label + self.value = value + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer(minLength: 4) + Text(value ?? "—") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } +} + +private struct SectionHeader: View { + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.secondary) + .tracking(0.5) + Rectangle() + .fill(Color.secondary.opacity(0.3)) + .frame(height: 0.5) + } + .padding(.top, 6) + } +} + +// MARK: - Formatters + +private enum FollowStatusFormat { + /// "1.47 U" / "27.5 U" / "—" if value is nil. + static func units(_ value: Double?, decimals: Int, suffix: String) -> String? { + guard let value = value else { return nil } + return String(format: "%.\(decimals)f%@", value, suffix) + } + + /// Break an OpenAPS/Trio "reason" blob onto multiple lines so each piece + /// of data is readable on the watch. The reason text uses a mix of `,`, + /// `;`, and `.` to separate phrases — split on any of those when followed + /// by whitespace (or end-of-string), which preserves numeric periods like + /// "0.13U" while breaking phrases like "Eventual BG 117 >= 99 ; Insulin + /// req 0.13U" into two lines. + static func formatReason(_ reason: String) -> String { + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + let pattern = "\\s*[,;.](?:\\s+|$)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return trimmed } + let ns = trimmed as NSString + let normalized = regex.stringByReplacingMatches( + in: trimmed, + range: NSRange(location: 0, length: ns.length), + withTemplate: "\n" + ) + return normalized + .split(separator: "\n", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + + /// Merge a "currently enacted" value with its scheduled counterpart into a + /// single row string. When they're equal (or one side is missing) just the + /// available value is shown; when they differ, "scheduled → current". + /// `suffix` is appended once at the end so we don't repeat units like + /// "1.10 U/hr → 0.95 U/hr". + static func currentVsScheduled( + current: Double?, + scheduled: Double?, + valueFormatter: (Double) -> String, + suffix: String = "" + ) -> String? { + let c = current.map(valueFormatter) + let s = scheduled.map(valueFormatter) + if let c = c, let s = s { + return c == s ? "\(c)\(suffix)" : "\(s) \u{2192} \(c)\(suffix)" + } + if let c = c { return "\(c)\(suffix)" } + if let s = s { return "\(s)\(suffix)" } + return nil + } + + /// Format a BG-like value (target / eventual / ISF) respecting the user's units. + /// OpenAPS may emit values in either mg/dL or mmol/L; we auto-detect by magnitude. + static func bgLike(_ value: Double?, units: String, withUnits: Bool = false) -> String? { + guard let value = value else { return nil } + if units == "mmol/L" { + // If the value already looks like mmol/L (small), keep it. Otherwise convert. + let mmol = value < 40 ? value : value / 18.0182 + return String(format: withUnits ? "%.1f mmol/L" : "%.1f", mmol) + } + // mg/dL: if value looks like mmol/L (small), convert up. + let mgdl = value < 40 ? value * 18.0182 : value + return String(format: withUnits ? "%.0f mg/dL" : "%.0f", mgdl.rounded()) + } + + /// "9:41 PM" + static func clock(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "h:mm a" + return f.string(from: date) + } + + /// "now" / "1m" / "12m" + static func relative(_ date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 30 { return "now" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + /// "1d 14h" / "9h 32m" + static func age(_ date: Date) -> String { + let total = Int(Date().timeIntervalSince(date)) + guard total > 0 else { return "—" } + let days = total / 86400 + let hours = (total % 86400) / 3600 + let minutes = (total % 3600) / 60 + if days > 0 { return "\(days)d \(hours)h" } + if hours > 0 { return "\(hours)h \(minutes)m" } + return "\(minutes)m" + } + + /// Render a profile-schedule timeAsSeconds (seconds since local midnight) + /// in the profile's timezone — "12:00 AM", "2:00 AM" … + static func timeOfDay(_ secondsFromMidnight: Double, timezone: TimeZone) -> String { + let hour = Int(secondsFromMidnight) / 3600 + let minute = (Int(secondsFromMidnight) % 3600) / 60 + var components = DateComponents() + components.hour = hour + components.minute = minute + components.timeZone = timezone + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timezone + guard let date = calendar.date(from: components) else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.timeZone = timezone + return formatter.string(from: date) + } +} diff --git a/LoopFollowWatch/Info.plist b/LoopFollowWatch/Info.plist new file mode 100644 index 000000000..07306cf27 --- /dev/null +++ b/LoopFollowWatch/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LoopFollow + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + 1 + WKApplication + + WKCompanionAppBundleIdentifier + com.$(unique_id).LoopFollow$(app_suffix) + UIBackgroundModes + + fetch + + + diff --git a/LoopFollowWatch/LoopFollowWatch.entitlements b/LoopFollowWatch/LoopFollowWatch.entitlements new file mode 100644 index 000000000..b14d8a63c --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.loopfollow.shared + + + diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift new file mode 100644 index 000000000..0969abbd6 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -0,0 +1,141 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import SwiftUI +import UserNotifications +import WatchKit +import WidgetKit + +class ExtensionDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { + func applicationDidFinishLaunching() { + WatchSessionManager.shared.startSession() + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + + // Kick off the first background refresh request immediately. + Self.scheduleBackgroundRefresh() + } + + // MARK: - Background Task Handling + + /// Called by the system when a scheduled background task fires. + /// This is the key mechanism for keeping the complication up-to-date every ~15 min + /// even when the app isn't in the foreground. + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let refreshTask as WKApplicationRefreshBackgroundTask: + // Fetch fresh BG data in the background. BGFetcher.shared is + // a process-wide singleton, so it's guaranteed to exist here + // regardless of whether the app was cold-launched by the + // system or brought to the foreground by the user. + if let config = WatchSessionManager.shared.config, + config.hasAnySource + { + BGFetcher.shared.fetch(config: config) + // Give the network requests a few seconds to land, then complete. + DispatchQueue.main.asyncAfter(deadline: .now() + 12) { + WidgetCenter.shared.reloadTimelines(ofKind: "BGComplication") + refreshTask.setTaskCompletedWithSnapshot(false) + } + } else { + refreshTask.setTaskCompletedWithSnapshot(false) + } + // Always schedule the next one + Self.scheduleBackgroundRefresh() + + case let snapshotTask as WKSnapshotRefreshBackgroundTask: + snapshotTask.setTaskCompleted( + restoredDefaultState: true, + estimatedSnapshotExpiration: Date.distantFuture, + userInfo: nil + ) + + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } + + /// Schedule the next background app refresh. The preferred date is + /// computed from the last known BG reading's timestamp so the wake-up + /// lands right after the next reading is expected on Nightscout, rather + /// than on a fixed 5-minute cadence that can perpetually fire just before + /// each new reading arrives. Uses the same adaptive logic as the + /// foreground timer in `BGFetcher`. + static func scheduleBackgroundRefresh() { + let lastBGTimestamp = WidgetData.load()?.bgTimestamp + let delay = BGFetcher.nextFetchDelay(afterReadingAt: lastBGTimestamp) + let preferredDate = Date().addingTimeInterval(delay) + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: preferredDate, + userInfo: nil + ) { error in + if let error = error { + print("[BGRefresh] Failed to schedule: \(error.localizedDescription)") + } + } + } + + // Show notifications even when the app is in the foreground + func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } +} + +@main +struct LoopFollowWatchApp: App { + @WKApplicationDelegateAdaptor(ExtensionDelegate.self) var delegate + + @StateObject private var sessionManager = WatchSessionManager.shared + @StateObject private var bgFetcher = BGFetcher.shared + @StateObject private var router = NavigationRouter() + + var body: some Scene { + WindowGroup { + TabView(selection: $router.activeTab) { + ContentView(sessionManager: sessionManager, bgFetcher: bgFetcher) + .edgesIgnoringSafeArea(.vertical) + .tag(0) + + if let config = sessionManager.config, config.remoteEnabled { + RemoteControlView(config: config, bgFetcher: bgFetcher, router: router) + .tag(1) + } + + if let config = sessionManager.config { + StatsView(bgFetcher: bgFetcher, config: config) + .edgesIgnoringSafeArea(.vertical) + .tag(2) + } + } + .tabViewStyle(.page) + .onOpenURL { url in + router.handle(url) + } + .onChange(of: sessionManager.config) { newConfig in + if let config = newConfig, config.hasAnySource { + bgFetcher.start(config: config) + } else { + bgFetcher.stop() + } + } + .onAppear { + // Free foreground reload — doesn't count toward daily budget + WidgetCenter.shared.reloadTimelines(ofKind: "BGComplication") + + if let config = sessionManager.config, config.hasAnySource { + bgFetcher.start(config: config) + } else { + // No config yet — ask iPhone to send it + sessionManager.requestConfigFromPhone() + } + } + } + } +} diff --git a/LoopFollowWatch/LoopStatus.swift b/LoopFollowWatch/LoopStatus.swift new file mode 100644 index 000000000..bbcba35e7 --- /dev/null +++ b/LoopFollowWatch/LoopStatus.swift @@ -0,0 +1,41 @@ +// LoopFollow +// LoopStatus.swift + +import Foundation + +struct LoopStatus { + let iob: Double? + let cob: Double? + let basalRate: Double? + let overrideActive: Bool + let overrideText: String? + let timestamp: Date + + // Predictions — Loop has one array, OpenAPS has four + let predictions: [Double]? + let predictionStart: Date? + let ztPredictions: [Double]? + let iobPredictions: [Double]? + let cobPredictions: [Double]? + let uamPredictions: [Double]? + let isOpenAPS: Bool + + // Temp target (from devicestatus or treatments) + let tempTargetActive: Bool + let tempTargetText: String? + + // Bolus calculation values from devicestatus + let recommendedBolus: Double? // Loop only — direct from devicestatus + let isf: Double? // OpenAPS — enacted/suggested ISF (autosens-adjusted) + let carbRatio: Double? // OpenAPS — from reason string + let currentTarget: Double? // OpenAPS — enacted/suggested current_target + + // Extended OpenAPS/Trio fields surfaced in the Follow Status sheet + let autosensRatio: Double? // OpenAPS — sensitivityRatio (1.00 == 100%) + let eventualBG: Double? // OpenAPS — eventualBG + let tdd: Double? // OpenAPS — TDD (units) + let minPredBG: Double? // OpenAPS — min across all predBGs.* arrays + let maxPredBG: Double? // OpenAPS — max across all predBGs.* arrays + let insulinReq: Double? // OpenAPS — insulinReq + let reason: String? // OpenAPS/Loop — full reason free-text +} diff --git a/LoopFollowWatch/NavigationRouter.swift b/LoopFollowWatch/NavigationRouter.swift new file mode 100644 index 000000000..6302ae8bc --- /dev/null +++ b/LoopFollowWatch/NavigationRouter.swift @@ -0,0 +1,45 @@ +// LoopFollow +// NavigationRouter.swift + +import SwiftUI + +enum DeepLinkDestination: Hashable { + case bolus, meal, override, tempTarget +} + +class NavigationRouter: ObservableObject { + /// 0 = ContentView (BG display), 1 = RemoteControlView, 2 = StatsView + @Published var activeTab: Int = 0 + + /// The currently presented (or about-to-be-presented) destination inside RemoteControlView's NavigationStack. + /// Setting this to a non-nil value pushes the corresponding screen; setting it to nil pops back to the grid. + @Published var activeDestination: DeepLinkDestination? + + /// Parse a deep link URL and navigate to the appropriate screen. + /// URLs: loopfollow://open (main graph), loopfollow://bolus, loopfollow://meal, loopfollow://override, loopfollow://temptarget + func handle(_ url: URL) { + guard url.scheme == "loopfollow" else { return } + + // Clear any active navigation first + activeDestination = nil + + switch url.host { + case "bolus": navigateTo(.bolus) + case "meal": navigateTo(.meal) + case "override": navigateTo(.override) + case "temptarget": navigateTo(.tempTarget) + default: + // "open" or unknown — stay on main graph (tab 0) + activeTab = 0 + } + } + + /// Switch to the Remote tab, then push the destination after a brief delay + /// so the tab switch can settle before the NavigationStack push fires. + private func navigateTo(_ dest: DeepLinkDestination) { + activeTab = 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.activeDestination = dest + } + } +} diff --git a/LoopFollowWatch/OverridePreset.swift b/LoopFollowWatch/OverridePreset.swift new file mode 100644 index 000000000..0b96fa4a8 --- /dev/null +++ b/LoopFollowWatch/OverridePreset.swift @@ -0,0 +1,12 @@ +// LoopFollow +// OverridePreset.swift + +import Foundation + +struct OverridePreset: Identifiable { + let id = UUID() + let name: String + let duration: Double? + let percentage: Double? + let target: Double? +} diff --git a/LoopFollowWatch/RemoteControlView.swift b/LoopFollowWatch/RemoteControlView.swift new file mode 100644 index 000000000..c6261de5c --- /dev/null +++ b/LoopFollowWatch/RemoteControlView.swift @@ -0,0 +1,113 @@ +// LoopFollow +// RemoteControlView.swift + +import SwiftUI + +private let tempColor = Color(red: 0.2, green: 0.9, blue: 0.1) + +struct RemoteControlView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + @ObservedObject var router: NavigationRouter + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ] + + var body: some View { + NavigationStack { + LazyVGrid(columns: columns, spacing: 8) { + Button { + router.activeDestination = .bolus + } label: { + RemoteTile(icon: "drop.fill", label: "Bolus", color: .blue) + } + .buttonStyle(.plain) + + Button { + router.activeDestination = .meal + } label: { + RemoteTile(icon: "fork.knife", label: "Meal", color: .yellow) + } + .buttonStyle(.plain) + + Button { + router.activeDestination = .override + } label: { + RemoteTile(icon: "bolt.fill", label: "Override", color: .purple) + } + .buttonStyle(.plain) + + Button { + router.activeDestination = .tempTarget + } label: { + RemoteTile(icon: "target", label: "Temp", color: tempColor) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 4) + .padding(.top, 2) + .navigationDestination(item: $router.activeDestination) { destination in + switch destination { + case .bolus: + WatchBolusView( + config: config, + bgFetcher: bgFetcher, + popToRoot: { router.activeDestination = nil } + ) + case .meal: + WatchMealView( + config: config, + bgFetcher: bgFetcher, + popToRoot: { router.activeDestination = nil } + ) + case .override: + WatchOverrideView(config: config, bgFetcher: bgFetcher) + case .tempTarget: + WatchTempTargetView(config: config, bgFetcher: bgFetcher) + } + } + } + } +} + +private struct RemoteTile: View { + let icon: String + let label: String + let color: Color + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(.white) + Text(label) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .frame(height: 72) + .background( + ZStack { + // Base gradient — lighter top, darker bottom for 3D depth + LinearGradient( + colors: [color, color.opacity(0.85)], + startPoint: .top, + endPoint: .bottom + ) + // Top highlight for raised look + VStack { + LinearGradient( + colors: [Color.white.opacity(0.08), Color.white.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 14) + Spacer() + } + } + ) + .cornerRadius(6) + } +} diff --git a/LoopFollowWatch/StatsView.swift b/LoopFollowWatch/StatsView.swift new file mode 100644 index 000000000..e58c97cd8 --- /dev/null +++ b/LoopFollowWatch/StatsView.swift @@ -0,0 +1,280 @@ +// LoopFollow +// StatsView.swift + +import Charts +import SwiftUI + +struct StatsView: View { + @ObservedObject var bgFetcher: BGFetcher + let config: WatchConfig + + var body: some View { + let stats = StatsCompute.compute( + history: bgFetcher.bgHistory, + lowLine: config.lowLine, + highLine: config.highLine + ) + + // Pie at the top (.padding(.top, 30) clears the status bar), + // small fixed gap, stats grid, and a flex filler below that + // absorbs any remaining vertical space. No bottom footer — + // positioning it above the TabView page-indicator dots across + // watch sizes was unreliable, and the reading count isn't + // essential info on the watch. + // + // The pie lives inside a GeometryReader scoped just to its row + // so it can dynamically size itself from screen width (62% + // capped at 105pt). The reader has a fixed 105pt height — on + // smaller watches the pie shrinks but keeps its slot. + VStack(spacing: 0) { + // Soft flexible top spacer — collapses on small watches, + // grows up to 20pt on bigger ones to balance the empty + // space the bottom Color.clear would otherwise hog. + Spacer(minLength: 0).frame(maxHeight: 20) + + GeometryReader { geo in + let pieSize = min(geo.size.width * 0.527, 89) + pieChart(stats: stats) + .frame(width: pieSize, height: pieSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(height: 89) + .padding(.top, 38) + + // Moderate gap between pie and stats grid + Spacer().frame(height: 12) + + statsGrid(stats: stats) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 6) + + Color.clear.frame(maxHeight: .infinity) + } + } + + @ViewBuilder + private func pieChart(stats: StatsResult?) -> some View { + if let stats = stats, stats.count > 0 { + if stats.countRange == stats.count { + // 100% in range — celebrate with a shades emoji inside + // a solid green ring (xdrip4ios-inspired). GeometryReader + // sizes the emoji proportionally to the pie so it fills + // the ring cleanly on every watch size. + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + ZStack { + Circle() + .strokeBorder(Color.green, lineWidth: max(3, side * 0.05)) + Text("\u{1F60E}") // 😎 + .font(.system(size: side * 0.55)) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } else { + let slices: [PieSlice] = [ + PieSlice(name: "Low", count: stats.countLow, color: .red), + PieSlice(name: "In Range", count: stats.countRange, color: .green), + PieSlice(name: "High", count: stats.countHigh, color: .yellow), + ] + Chart(slices) { slice in + SectorMark( + angle: .value("Count", slice.count), + innerRadius: .ratio(0), + angularInset: 0 + ) + .foregroundStyle(slice.color) + } + .chartLegend(.hidden) + } + } else { + Circle() + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 2) + } + } + + private func statsGrid(stats: StatsResult?) -> some View { + VStack(spacing: 4) { + HStack(spacing: 2) { + StatCell( + label: "Low", + value: percentText(stats?.percentLow) + ) + StatCell(label: "In Range", value: percentText(stats?.percentRange)) + StatCell( + label: "High", + value: percentText(stats?.percentHigh) + ) + } + HStack(spacing: 2) { + StatCell(label: "Avg BG", value: avgBGText(stats?.avgBG)) + StatCell(label: "Est A1C", value: a1cText(stats?.a1c)) + StatCell(label: "Std Dev", value: stdDevText(stats?.stdDev)) + } + } + } + + // MARK: - Formatting + + private func percentText(_ value: Double?) -> String { + guard let value = value else { return "—" } + return String(format: "%.1f%%", value) + } + + private func avgBGText(_ mgdl: Double?) -> String { + guard let mgdl = mgdl else { return "—" } + if config.units == "mmol/L" { + return String(format: "%.1f", mgdl / 18.0182) + } + return "\(Int(mgdl.rounded()))" + } + + private func a1cText(_ value: Double?) -> String { + guard let value = value else { return "—" } + return String(format: "%.1f%%", value) + } + + private func stdDevText(_ mgdl: Double?) -> String { + guard let mgdl = mgdl else { return "—" } + if config.units == "mmol/L" { + return String(format: "%.2f", mgdl / 18.0182) + } + return String(format: "%.2f", mgdl) + } + + /// Display the first in-range value on either side of a threshold, + /// nudged by `delta` mg/dL (±1 for Low/High labels). E.g. with + /// lowLine=69 and delta=+1 this yields "70"; with highLine=181 and + /// delta=-1 it yields "180". mmol/L users get a 0.1 mmol nudge. + private func rangeEdgeDisplay(_ mgdl: Double, delta: Int) -> String { + if config.units == "mmol/L" { + let mmol = mgdl / 18.0182 + 0.1 * Double(delta) + return String(format: "%.1f", mmol) + } + return "\(Int(mgdl.rounded()) + delta)" + } +} + +// MARK: - Stat cell + +private struct StatCell: View { + let label: String + let value: String + let suffix: String? + + init(label: String, value: String, suffix: String? = nil) { + self.label = label + self.value = value + self.suffix = suffix + } + + var body: some View { + VStack(spacing: 0) { + labelText + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(value) + .font(.system(size: 12, weight: .medium, design: .default)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(maxWidth: .infinity) + } + + /// Label + optional threshold annotation, e.g. "Low (<70)". Heading + /// and threshold share a line via Text concatenation so they scale + /// together when space is tight. Every run on the stats page uses + /// the same 12pt medium font for a uniform look. The space before + /// "(" is omitted so the High cell ("High(>180)") fits in its + /// column on narrow watches without triggering minimumScaleFactor + /// and rendering smaller than the Low cell. + private var labelText: Text { + let base = Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + guard let suffix = suffix else { return base } + return base + Text("(\(suffix))") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + } +} + +// MARK: - Pie slice model + +private struct PieSlice: Identifiable { + let id = UUID() + let name: String + let count: Int + let color: Color +} + +// MARK: - Stats compute + +struct StatsResult { + let countLow: Int + let countRange: Int + let countHigh: Int + let count: Int + let percentLow: Double + let percentRange: Double + let percentHigh: Double + let avgBG: Double // always mg/dL; convert at display time + let stdDev: Double // always mg/dL; convert at display time + let a1c: Double // percent (NGSP) +} + +enum StatsCompute { + /// Compute 24h distribution / averages from an in-memory BG history. + /// Matches LoopFollow/Controllers/Stats.swift formulas exactly. + /// Returns nil when the 24h window is empty. + static func compute( + history: [BGReading], + lowLine: Double, + highLine: Double + ) -> StatsResult? { + let cutoff = Date().addingTimeInterval(-24 * 3600) + let window = history.filter { $0.timestamp >= cutoff } + guard !window.isEmpty else { return nil } + + var countLow = 0 + var countRange = 0 + var countHigh = 0 + var totalGlucose = 0 + for reading in window { + let bg = Double(reading.bgValue) + totalGlucose += reading.bgValue + if bg <= lowLine { + countLow += 1 + } else if bg >= highLine { + countHigh += 1 + } else { + countRange += 1 + } + } + + let count = window.count + let avgBG = Double(totalGlucose) / Double(count) + + var partialSum: Double = 0 + for reading in window { + let diff = Double(reading.bgValue) - avgBG + partialSum += diff * diff + } + let stdDev = sqrt(partialSum / Double(count)) + + let a1c = (avgBG + 46.7) / 28.7 + + return StatsResult( + countLow: countLow, + countRange: countRange, + countHigh: countHigh, + count: count, + percentLow: Double(countLow) / Double(count) * 100, + percentRange: Double(countRange) / Double(count) * 100, + percentHigh: Double(countHigh) / Double(count) * 100, + avgBG: avgBG, + stdDev: stdDev, + a1c: a1c + ) + } +} diff --git a/LoopFollowWatch/Treatment.swift b/LoopFollowWatch/Treatment.swift new file mode 100644 index 000000000..20e17f816 --- /dev/null +++ b/LoopFollowWatch/Treatment.swift @@ -0,0 +1,32 @@ +// LoopFollow +// Treatment.swift + +import Foundation + +struct Treatment: Identifiable { + let id = UUID() + let timestamp: Date + let type: TreatmentType + let value: Double // insulin units or carb grams + + enum TreatmentType { + case bolus, smb, carbs + } +} + +struct TempTargetEntry: Identifiable { + let id = UUID() + let startDate: Date + let endDate: Date + let targetTop: Double + let targetBottom: Double + let reason: String +} + +struct OverrideEntry: Identifiable { + let id = UUID() + let startDate: Date + let endDate: Date + let percentage: Double? + let name: String +} diff --git a/LoopFollowWatch/WatchBolusView.swift b/LoopFollowWatch/WatchBolusView.swift new file mode 100644 index 000000000..70f390989 --- /dev/null +++ b/LoopFollowWatch/WatchBolusView.swift @@ -0,0 +1,393 @@ +// LoopFollow +// WatchBolusView.swift + +import SwiftUI +import WatchKit + +/// Optional meal data passed from the meal screen for the meal→bolus flow. +struct PendingMealData { + let carbs: Int + let protein: Int? + let fat: Int? + let timeOffset: Double // minutes offset from now +} + +struct WatchBolusView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + var pendingMeal: PendingMealData? + var popToRoot: (() -> Void)? + @Environment(\.dismiss) private var dismiss + @State private var rawCrown: Double = 0 + @State private var lastHapticAmount: Double = 0 + @State private var confirmedAmount: Double = 0 + @State private var showConfirm = false + @State private var resultMessage: String? + @State private var isError = false + @State private var showCalcDetail = false + @State private var showCelebration = false + @State private var isRefreshing = true + + /// The displayed amount, snapped to 0.05U increments + private var amount: Double { + let scaled = rawCrown * 0.25 + let snapped = (scaled / 0.05).rounded() * 0.05 + return min(max(snapped, 0), config.maxBolus) + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + if let result = resultMessage { + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() + } + CelebrationOverlay(isActive: $showCelebration) + } + } else if showConfirm { + confirmSummary + .padding(.bottom, 12) + + CrownConfirmView(label: confirmedAmount > 0 ? "to deliver" : "to send meal") { + sendBolusAndMeal() + } + + } else { + HStack { + Button { + rawCrown = max(rawCrown - 1.0, 0) + WKInterfaceDevice.current().play(.click) + } label: { + Text("−") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.blue) + .frame(width: 32, height: 32) + .background(Color.blue.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.leading, 8) + + Spacer() + + Text("Bolus") + .font(.system(size: 16, weight: .semibold)) + + Spacer() + + Button { + rawCrown = min(rawCrown + 1.0, config.maxBolus / 0.25) + WKInterfaceDevice.current().play(.click) + } label: { + Text("+") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.blue) + .frame(width: 32, height: 32) + .background(Color.blue.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 16) + + Text(String(format: "%.2f U", amount)) + .font(.system(size: 60, weight: .bold, design: .rounded)) + .foregroundColor(.blue) + .lineLimit(1) + .minimumScaleFactor(0.7) + + HStack(spacing: 6) { + Text("Calculated: \(String(format: "%.2f", bgFetcher.recommendedBolus))U") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.blue) + .lineLimit(1) + .minimumScaleFactor(0.7) + .onTapGesture { + rawCrown = min(bgFetcher.recommendedBolus, config.maxBolus) / 0.25 + } + if bgFetcher.bolusCalc != nil { + Button { + showCalcDetail = true + } label: { + Image(systemName: "info.circle") + .font(.system(size: 20)) + .foregroundColor(.blue.opacity(0.8)) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 8) + .padding(.top, -8) + + Button(amount > 0 ? "Confirm" : (pendingMeal != nil ? "Skip" : "Confirm")) { + confirmedAmount = amount + showConfirm = true + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(amount <= 0 && pendingMeal == nil) + } + } + .modifier(CrownRotationModifier( + isActive: !showConfirm && resultMessage == nil, + value: $rawCrown, + from: 0, + through: config.maxBolus / 0.25, + by: 0.01, + sensitivity: .low + )) + .onChange(of: rawCrown) { _ in + let current = amount + if current != lastHapticAmount { + lastHapticAmount = current + WKInterfaceDevice.current().play(.click) + } + } + .sheet(isPresented: $showCalcDetail) { + if let calc = bgFetcher.bolusCalc { + BolusCalcDetailView(calc: calc, recommended: bgFetcher.recommendedBolus) + } + } + .navigationBarBackButtonHidden(showConfirm) + .toolbar { + if showConfirm { + ToolbarItem(placement: .cancellationAction) { + Button { + showConfirm = false + } label: { + Image(systemName: "chevron.left") + } + } + } + } + .onAppear { + if pendingMeal == nil { + bgFetcher.pendingCarbs = 0 + } + isRefreshing = true + bgFetcher.fetchDeviceStatus(config: config) { + bgFetcher.updateRecommendedBolus() + isRefreshing = false + } + } + .onDisappear { + bgFetcher.pendingCarbs = 0 + } + + if isRefreshing { + Color.black.opacity(0.6) + .ignoresSafeArea() + ProgressView() + .tint(.blue) + } + } + } + + @ViewBuilder + private var confirmSummary: some View { + VStack(spacing: 4) { + Label(String(format: "%.2f U", confirmedAmount), systemImage: "drop.fill") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(confirmedAmount > 0 ? .blue : .secondary) + if let meal = pendingMeal { + HStack(spacing: 8) { + Label("\(meal.carbs)g", systemImage: "fork.knife") + .foregroundColor(.yellow) + if let f = meal.fat, f > 0 { + Label("\(f)g", systemImage: "circle.hexagongrid.fill") + .foregroundColor(.orange) + } + if let p = meal.protein, p > 0 { + Label("\(p)g", systemImage: "figure.strengthtraining.functional") + .foregroundColor(.orange) + } + } + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + } + + private func autoDismiss() { + let delay = showCelebration ? CelebrationOverlay.displayDuration : 3.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + if let popToRoot = popToRoot { + popToRoot() + } else { + dismiss() + } + } + } + + private func sendBolusAndMeal() { + if confirmedAmount > 0 { + // Send bolus first — if carbs arrived before the bolus, Trio could + // auto-dose on the carbs and stack with our remote bolus. + sendBolus() + } else if let meal = pendingMeal { + // Skip (0U) — send meal only + sendMeal(meal) + } + } + + private func sendBolus() { + WatchRemoteService.sendBolus(amount: confirmedAmount, config: config) { success, error in + if success { + bgFetcher.pendingInsulin += confirmedAmount + bgFetcher.updateRecommendedBolus() + if let meal = pendingMeal { + // Bolus succeeded — now safe to send carbs + sendMeal(meal) + } else { + bgFetcher.pendingCarbs = 0 + resultMessage = "Bolus sent!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Bolus Sent", + body: String(format: "%.2fU bolus command sent", confirmedAmount) + ) + autoDismiss() + } + } else { + bgFetcher.pendingCarbs = 0 + resultMessage = error ?? "Failed" + isError = true + } + } + } + + private func sendMeal(_ meal: PendingMealData) { + let mealProtein = (config.mealWithFatProtein && meal.protein != nil && meal.protein! > 0) ? meal.protein : nil + let mealFat = (config.mealWithFatProtein && meal.fat != nil && meal.fat! > 0) ? meal.fat : nil + let mealTime = abs(meal.timeOffset) >= 1 ? Date().addingTimeInterval(meal.timeOffset * 60) : nil + + WatchRemoteService.sendMeal( + carbs: meal.carbs, + protein: mealProtein, + fat: mealFat, + entryTime: mealTime, + config: config + ) { success, error in + // Always clear pending carbs — whether the send succeeded or failed, + // the meal flow is done and we must not double-count. + bgFetcher.pendingCarbs = 0 + + if success { + if confirmedAmount > 0 { + resultMessage = "Bolus + Meal\nsent!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Bolus + Meal Sent", + body: String(format: "%.2fU bolus + %dg carbs", confirmedAmount, meal.carbs) + ) + } else { + resultMessage = "Meal sent!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Meal Sent", + body: "\(meal.carbs)g carbs logged" + ) + } + autoDismiss() + } else { + resultMessage = error ?? "Meal failed" + isError = true + } + } + } +} + +private struct BolusCalcDetailView: View { + let calc: BolusCalculation + let recommended: Double + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + Text("Bolus Calculation") + .font(.system(size: 14, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .center) + + calcRow( + label: "GLUCOSE", + detail: "(\(fmtInt(calc.bg)) − \(fmtInt(calc.target))) / \(fmtInt(calc.isf))", + result: calc.glucoseEffect + ) + + calcRow( + label: "IOB", + detail: "−1 × \(fmt(calc.iob))", + result: calc.iobEffect + ) + + let totalCarbs = calc.cob + calc.pendingCarbs + calcRow( + label: "COB", + detail: "(\(fmtInt(calc.cob)) + \(fmtInt(calc.pendingCarbs))) / \(fmtInt(calc.cr))", + result: calc.cobEffect + ) + + calcRow( + label: "DELTA", + detail: "\(fmtInt(calc.delta)) / \(fmtInt(calc.isf))", + result: calc.deltaEffect + ) + + Divider() + + HStack { + Text("Full Bolus") + .font(.system(size: 11, weight: .semibold)) + Spacer() + Text(fmt(calc.fullBolus)) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(calc.fullBolus >= 0 ? .green : .red) + } + + HStack { + Text("Recommended") + .font(.system(size: 11, weight: .semibold)) + Spacer() + Text("\(fmt(recommended)) U") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.blue) + } + } + .padding(.horizontal, 4) + } + } + + @ViewBuilder + private func calcRow(label: String, detail: String, result: Double) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + HStack { + Text(detail) + .font(.system(size: 11, design: .rounded)) + .foregroundColor(.primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Spacer() + Text(fmt(result)) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(result >= 0 ? .green : .red) + } + } + } + + private func fmt(_ v: Double) -> String { String(format: "%.2f", v) } + private func fmtInt(_ v: Double) -> String { + v == v.rounded() ? String(format: "%.0f", v) : String(format: "%.1f", v) + } +} diff --git a/LoopFollowWatch/WatchConfig.swift b/LoopFollowWatch/WatchConfig.swift new file mode 100644 index 000000000..268354785 --- /dev/null +++ b/LoopFollowWatch/WatchConfig.swift @@ -0,0 +1,155 @@ +// LoopFollow +// WatchConfig.swift + +import Foundation + +struct WatchConfig: Equatable { + var nsURL: String + var nsToken: String + var dexUsername: String + var dexPassword: String + var dexServer: String // "US" or "NON_US" + var units: String // "mg/dL" or "mmol/L" + var lowLine: Double + var highLine: Double + + // Remote control fields + var remoteType: String // "None", "Nightscout", "Trio Remote Control", "Loop APNS" + var maxBolus: Double + var maxCarbs: Double + + // TRC APNS credentials + var trcDeviceToken: String + var trcSharedSecret: String + var trcApnsKey: String + var trcKeyId: String + var trcTeamId: String + var trcBundleId: String + var trcProductionEnv: Bool + var trcUser: String + + // LoopFollow's own APNS credentials — used to populate + // `return_notification` so Trio's ack lands on the iPhone, which + // can forward it to the watch over WCSession. + var lfDeviceToken: String + var lfApnsKey: String + var lfKeyId: String + var lfTeamId: String + var lfBundleId: String + var lfProductionEnv: Bool + + // Nightscout write auth + var nsWriteAuth: Bool + + // Meal settings (synced from iPhone) + var mealWithFatProtein: Bool + var maxProtein: Double + var maxFat: Double + + var hasDexcomCredentials: Bool { + !dexUsername.isEmpty && !dexPassword.isEmpty + } + + var hasNightscoutURL: Bool { + !nsURL.isEmpty + } + + var hasAnySource: Bool { + hasDexcomCredentials || hasNightscoutURL + } + + var remoteEnabled: Bool { + remoteType != "None" + } + + var dexServerURL: String { + dexServer == "US" + ? "https://share2.dexcom.com" + : "https://shareous1.dexcom.com" + } + + func toDictionary() -> [String: Any] { + [ + "nsURL": nsURL, + "nsToken": nsToken, + "dexUsername": dexUsername, + "dexPassword": dexPassword, + "dexServer": dexServer, + "units": units, + "lowLine": lowLine, + "highLine": highLine, + "remoteType": remoteType, + "maxBolus": maxBolus, + "maxCarbs": maxCarbs, + "trcDeviceToken": trcDeviceToken, + "trcSharedSecret": trcSharedSecret, + "trcApnsKey": trcApnsKey, + "trcKeyId": trcKeyId, + "trcTeamId": trcTeamId, + "trcBundleId": trcBundleId, + "trcProductionEnv": trcProductionEnv, + "trcUser": trcUser, + "lfDeviceToken": lfDeviceToken, + "lfApnsKey": lfApnsKey, + "lfKeyId": lfKeyId, + "lfTeamId": lfTeamId, + "lfBundleId": lfBundleId, + "lfProductionEnv": lfProductionEnv, + "nsWriteAuth": nsWriteAuth, + "mealWithFatProtein": mealWithFatProtein, + "maxProtein": maxProtein, + "maxFat": maxFat, + ] + } + + init(from dict: [String: Any]) { + nsURL = dict["nsURL"] as? String ?? "" + nsToken = dict["nsToken"] as? String ?? "" + dexUsername = dict["dexUsername"] as? String ?? "" + dexPassword = dict["dexPassword"] as? String ?? "" + dexServer = dict["dexServer"] as? String ?? "US" + units = dict["units"] as? String ?? "mg/dL" + lowLine = dict["lowLine"] as? Double ?? 70.0 + highLine = dict["highLine"] as? Double ?? 180.0 + remoteType = dict["remoteType"] as? String ?? "None" + maxBolus = dict["maxBolus"] as? Double ?? 10.0 + maxCarbs = dict["maxCarbs"] as? Double ?? 100.0 + trcDeviceToken = dict["trcDeviceToken"] as? String ?? "" + trcSharedSecret = dict["trcSharedSecret"] as? String ?? "" + trcApnsKey = dict["trcApnsKey"] as? String ?? "" + trcKeyId = dict["trcKeyId"] as? String ?? "" + trcTeamId = dict["trcTeamId"] as? String ?? "" + trcBundleId = dict["trcBundleId"] as? String ?? "" + trcProductionEnv = dict["trcProductionEnv"] as? Bool ?? false + trcUser = dict["trcUser"] as? String ?? "" + lfDeviceToken = dict["lfDeviceToken"] as? String ?? "" + lfApnsKey = dict["lfApnsKey"] as? String ?? "" + lfKeyId = dict["lfKeyId"] as? String ?? "" + lfTeamId = dict["lfTeamId"] as? String ?? "" + lfBundleId = dict["lfBundleId"] as? String ?? "" + lfProductionEnv = dict["lfProductionEnv"] as? Bool ?? false + nsWriteAuth = dict["nsWriteAuth"] as? Bool ?? false + mealWithFatProtein = dict["mealWithFatProtein"] as? Bool ?? false + maxProtein = dict["maxProtein"] as? Double ?? 30.0 + maxFat = dict["maxFat"] as? Double ?? 30.0 + } + + func saveToDefaults() { + let defaults = UserDefaults.standard + defaults.set(toDictionary(), forKey: "watchConfig") + + // Also mirror NS credentials to the App Group so the widget extension + // (a separate process) can fetch BG directly from Nightscout. + if let shared = UserDefaults(suiteName: WidgetData.appGroupID) { + shared.set(nsURL, forKey: "nsURL") + shared.set(nsToken, forKey: "nsToken") + } + } + + static func loadFromDefaults() -> WatchConfig? { + guard let dict = UserDefaults.standard.dictionary(forKey: "watchConfig") else { + return nil + } + return WatchConfig(from: dict) + } +} diff --git a/LoopFollowWatch/WatchMealView.swift b/LoopFollowWatch/WatchMealView.swift new file mode 100644 index 000000000..676bac136 --- /dev/null +++ b/LoopFollowWatch/WatchMealView.swift @@ -0,0 +1,268 @@ +// LoopFollow +// WatchMealView.swift + +import SwiftUI +import WatchKit + +struct WatchMealView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + var popToRoot: (() -> Void)? + @State private var carbs: Double = 0 + @State private var protein: Double = 0 + @State private var fat: Double = 0 + @State private var entryTimeOffset: Double = 0 // minutes offset from now (-240 to +240) + @State private var editingField: EditField? = .carbs + @State private var lastHapticValue: Int = 0 + @State private var showBolusStep = false + @FocusState private var crownFocused: Bool + + enum EditField { + case carbs, protein, fat, time + } + + private var entryTimeText: String { + if abs(entryTimeOffset) < 1 { return "Now" } + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + let entryTime = Date().addingTimeInterval(entryTimeOffset * 60) + return formatter.string(from: entryTime) + } + + /// Crown binding for the active editing field. + private var guardedCrownBinding: Binding { + Binding( + get: { + guard let field = editingField else { return 0 } + switch field { + case .carbs: return carbs + case .protein: return protein + case .fat: return fat + case .time: return entryTimeOffset + } + }, + set: { newValue in + guard let field = editingField else { return } + switch field { + case .carbs: carbs = newValue + case .protein: protein = newValue + case .fat: fat = newValue + case .time: entryTimeOffset = newValue + } + } + ) + } + + private var crownRange: ClosedRange { + guard let field = editingField else { return 0 ... 1 } + switch field { + case .carbs: return 0 ... config.maxCarbs + case .protein: return 0 ... config.maxProtein + case .fat: return 0 ... config.maxFat + case .time: return -240 ... 240 + } + } + + private var crownStep: Double { + guard let field = editingField else { return 1 } + switch field { + case .time: return 5 + default: return 1 + } + } + + private var pendingMealData: PendingMealData { + PendingMealData( + carbs: Int(carbs.rounded()), + protein: Int(protein.rounded()) > 0 ? Int(protein.rounded()) : nil, + fat: Int(fat.rounded()) > 0 ? Int(fat.rounded()) : nil, + timeOffset: entryTimeOffset + ) + } + + var body: some View { + Group { + if editingField != nil { + // ── Tile-editing mode ── + // ScrollView for identical layout, but crown modifiers on the + // wrapper outside it. .digitalCrownRotation() is ONLY in this + // branch, so it never poisons the browse branch's native scrolling. + ScrollView { + VStack(spacing: 4) { + entryView + } + } + .scrollDisabled(true) + .focusable() + .focused($crownFocused) + .digitalCrownRotation( + guardedCrownBinding, + from: crownRange.lowerBound, + through: crownRange.upperBound, + by: crownStep, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: false + ) + .onAppear { crownFocused = true } + } else { + // ── Browse mode ── + // Plain ScrollView, ZERO crown modifiers anywhere. + // Native watchOS crown scrolling works unimpeded. + ScrollView { + VStack(spacing: 4) { + entryView + } + } + } + } + .onChange(of: editingField) { field in + if field != nil { + crownFocused = true + } + } + .onChange(of: carbs) { _ in playHaptic(Int(carbs.rounded())) } + .onChange(of: protein) { _ in playHaptic(Int(protein.rounded())) } + .onChange(of: fat) { _ in playHaptic(Int(fat.rounded())) } + .onChange(of: entryTimeOffset) { _ in playHaptic(Int(entryTimeOffset)) } + .navigationDestination(isPresented: $showBolusStep) { + WatchBolusView(config: config, bgFetcher: bgFetcher, pendingMeal: pendingMealData, popToRoot: popToRoot) + } + .onChange(of: showBolusStep) { active in + if !active { + bgFetcher.pendingCarbs = 0 + bgFetcher.updateRecommendedBolus() + } + } + .onDisappear { + bgFetcher.pendingCarbs = 0 + } + } + + private let gridColumns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ] + + private var activeFieldLabel: String { + guard let field = editingField else { return "" } + switch field { + case .carbs: return "Carbs" + case .fat: return "Fat" + case .protein: return "Protein" + case .time: return "Time" + } + } + + private func adjustActiveField(by delta: Double) { + guard let field = editingField else { return } + switch field { + case .carbs: carbs = min(max(carbs + delta, 0), config.maxCarbs) + case .fat: fat = min(max(fat + delta, 0), config.maxFat) + case .protein: protein = min(max(protein + delta, 0), config.maxProtein) + case .time: entryTimeOffset = min(max(entryTimeOffset + delta, -240), 240) + } + WKInterfaceDevice.current().play(.click) + } + + private var stepSize: Double { + editingField == .time ? 15 : 5 + } + + @ViewBuilder + private var entryView: some View { + HStack { + Button { + adjustActiveField(by: -stepSize) + } label: { + Text("−") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.yellow) + .frame(width: 32, height: 32) + .background(Color.yellow.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Spacer() + + Text("Meal") + .font(.system(size: 16, weight: .semibold)) + + Spacer() + + Button { + adjustActiveField(by: stepSize) + } label: { + Text("+") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.yellow) + .frame(width: 32, height: 32) + .background(Color.yellow.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + + LazyVGrid(columns: gridColumns, spacing: 8) { + mealTile(label: "Carbs", value: "\(Int(carbs.rounded()))g", field: .carbs) + + if config.mealWithFatProtein { + mealTile(label: "Fat", value: "\(Int(fat.rounded()))g", field: .fat) + mealTile(label: "Protein", value: "\(Int(protein.rounded()))g", field: .protein) + } + + mealTile(label: "Time", value: entryTimeText, field: .time) + } + + Button("Continue") { + if carbs > 0 || protein > 0 || fat > 0 { + bgFetcher.pendingCarbs = Double(Int(carbs.rounded())) + bgFetcher.updateRecommendedBolus() + showBolusStep = true + } + } + .buttonStyle(.borderedProminent) + .tint(.yellow) + .disabled(carbs <= 0 && protein <= 0 && fat <= 0) + .opacity(editingField == nil ? 1 : 0) + .allowsHitTesting(editingField == nil) + } + + @ViewBuilder + private func mealTile(label: String, value: String, field: EditField) -> some View { + let isActive = editingField == field + Button { + editingField = isActive ? nil : field + } label: { + VStack(spacing: 2) { + Text(value) + .font(.system(size: 15, weight: .bold)) + .foregroundColor(isActive ? .yellow : .primary) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Text(label) + .font(.system(size: 12)) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(isActive ? Color.yellow.opacity(0.3) : Color.yellow.opacity(0.15)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.yellow.opacity(isActive ? 0.8 : 0), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func playHaptic(_ newValue: Int) { + if newValue != lastHapticValue { + lastHapticValue = newValue + WKInterfaceDevice.current().play(.click) + } + } +} diff --git a/LoopFollowWatch/WatchOverrideView.swift b/LoopFollowWatch/WatchOverrideView.swift new file mode 100644 index 000000000..1c52c295d --- /dev/null +++ b/LoopFollowWatch/WatchOverrideView.swift @@ -0,0 +1,173 @@ +// LoopFollow +// WatchOverrideView.swift + +import SwiftUI + +struct WatchOverrideView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + @Environment(\.dismiss) private var dismiss + @State private var selectedOverride: OverridePreset? + @State private var showConfirm = false + @State private var showCancelConfirm = false + @State private var resultMessage: String? + @State private var isError = false + @State private var showCelebration = false + + var body: some View { + Group { + if let result = resultMessage { + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() + } + CelebrationOverlay(isActive: $showCelebration) + } + } else { + ScrollView { + VStack(spacing: 6) { + if showConfirm, let override = selectedOverride { + Text(override.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.purple) + + if let pct = override.percentage { + Text(String(format: "%.0f%%", pct)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + CrownConfirmView(label: "to activate") { + sendOverride(name: override.name) + } + } else if showCancelConfirm { + Text("Cancel Override") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.red) + + CrownConfirmView(label: "to cancel") { + cancelOverride() + } + } else { + // Active override section (check both devicestatus and treatments) + if let activeOverride = activeOverrideEntry { + Text("Active Override") + .font(.system(size: 12)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(activeOverride.name + (activeOverride.percentage.map { String(format: " %.0f%%", $0) } ?? "")) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background(Color.purple.opacity(0.55)) + .cornerRadius(8) + + Button { + showCancelConfirm = true + } label: { + Text("Cancel Override") + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.red.opacity(0.3)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Divider() + } + + Text("Available Overrides") + .font(.system(size: 14, weight: .semibold)) + + if bgFetcher.overridePresets.isEmpty { + Text("No presets found.\nCheck Nightscout profile.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } else { + ForEach(bgFetcher.overridePresets) { preset in + Button { + selectedOverride = preset + showConfirm = true + } label: { + HStack { + Text(preset.name) + .font(.system(size: 15, weight: .medium)) + Spacer() + if let pct = preset.percentage { + Text(String(format: "%.0f%%", pct)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 14) + .background(Color.purple.opacity(0.55)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + } + } + } + } + } + } + + /// Returns the currently active override from treatments, or nil if none active. + private var activeOverrideEntry: OverrideEntry? { + let now = Date() + return bgFetcher.overrideEntries.first { $0.startDate <= now && $0.endDate > now } + } + + private func autoDismiss() { + let delay = showCelebration ? CelebrationOverlay.displayDuration : 3.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + dismiss() + } + } + + private func sendOverride(name: String) { + WatchRemoteService.sendOverride(name: name, config: config) { success, error in + if success { + resultMessage = "Override activated!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Override Activated", + body: "\(name) override command sent" + ) + autoDismiss() + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } + + private func cancelOverride() { + WatchRemoteService.cancelOverride(config: config) { success, error in + if success { + resultMessage = "Override cancelled" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Override Cancelled", + body: "Override cancel command sent" + ) + autoDismiss() + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} diff --git a/LoopFollowWatch/WatchRemoteService.swift b/LoopFollowWatch/WatchRemoteService.swift new file mode 100644 index 000000000..245d94c04 --- /dev/null +++ b/LoopFollowWatch/WatchRemoteService.swift @@ -0,0 +1,481 @@ +// LoopFollow +// WatchRemoteService.swift + +import CryptoKit +import Foundation +import UserNotifications +import WatchKit + +class WatchRemoteService { + // MARK: - Local Notification Helper + + static func postLocalNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + WKInterfaceDevice.current().play(.notification) + } + + // MARK: - Public API + + static func sendBolus(amount: Double, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "bolus", + timestamp: Date().timeIntervalSince1970, + bolusAmount: amount, + returnNotification: makeReturnNotificationInfo(config: config) + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + case "Nightscout": + let body: [String: Any] = [ + "enteredBy": "LoopFollow Watch", + "eventType": "Correction Bolus", + "insulin": amount, + "created_at": ISO8601DateFormatter().string(from: Date()), + ] + postNightscoutTreatment(body: body, config: config, completion: completion) + default: + completion(false, "Remote type not supported") + } + } + + static func sendMeal(carbs: Int, protein: Int? = nil, fat: Int? = nil, entryTime: Date? = nil, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + let timestamp = entryTime ?? Date() + switch config.remoteType { + case "Trio Remote Control": + var payload = TRCPayload( + user: config.trcUser, + commandType: "meal", + timestamp: Date().timeIntervalSince1970, + carbs: carbs, + returnNotification: makeReturnNotificationInfo(config: config) + ) + payload.protein = protein + payload.fat = fat + if let entryTime = entryTime { + payload.scheduledTime = entryTime.timeIntervalSince1970 + } + sendTRCCommand(payload: payload, config: config, completion: completion) + case "Nightscout": + var body: [String: Any] = [ + "enteredBy": "LoopFollow Watch", + "eventType": "Meal Bolus", + "carbs": carbs, + "created_at": ISO8601DateFormatter().string(from: timestamp), + ] + if let protein = protein { body["protein"] = protein } + if let fat = fat { body["fat"] = fat } + postNightscoutTreatment(body: body, config: config, completion: completion) + default: + completion(false, "Remote type not supported") + } + } + + static func sendTempTarget(target: Int, duration: Int, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "temp_target", + timestamp: Date().timeIntervalSince1970, + target: target, + duration: duration, + returnNotification: makeReturnNotificationInfo(config: config) + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + case "Nightscout": + let body: [String: Any] = [ + "enteredBy": "LoopFollow Watch", + "eventType": "Temporary Target", + "reason": "Manual", + "targetTop": Double(target), + "targetBottom": Double(target), + "duration": duration, + "created_at": ISO8601DateFormatter().string(from: Date()), + ] + postNightscoutTreatment(body: body, config: config, completion: completion) + default: + completion(false, "Remote type not supported") + } + } + + static func cancelTempTarget(config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "cancel_temp_target", + timestamp: Date().timeIntervalSince1970, + returnNotification: makeReturnNotificationInfo(config: config) + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + case "Nightscout": + let body: [String: Any] = [ + "enteredBy": "LoopFollow Watch", + "eventType": "Temporary Target", + "reason": "Manual", + "duration": 0, + "created_at": ISO8601DateFormatter().string(from: Date()), + ] + postNightscoutTreatment(body: body, config: config, completion: completion) + default: + completion(false, "Remote type not supported") + } + } + + static func sendOverride(name: String, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "start_override", + timestamp: Date().timeIntervalSince1970, + overrideName: name, + returnNotification: makeReturnNotificationInfo(config: config) + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + default: + completion(false, "Remote type not supported for overrides") + } + } + + static func cancelOverride(config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "cancel_override", + timestamp: Date().timeIntervalSince1970, + returnNotification: makeReturnNotificationInfo(config: config) + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + default: + completion(false, "Remote type not supported for overrides") + } + } + + // MARK: - TRC (Trio Remote Control) via APNS + + private struct TRCPayload: Encodable { + var user: String + var commandType: String + var timestamp: TimeInterval + + var bolusAmount: Double? + var target: Int? + var duration: Int? + var carbs: Int? + var protein: Int? + var fat: Int? + var overrideName: String? + var scheduledTime: TimeInterval? + var returnNotification: ReturnNotificationInfo? + + struct ReturnNotificationInfo: Encodable { + let productionEnvironment: Bool + let deviceToken: String + let bundleId: String + let teamId: String + let keyId: String + let apnsKey: String + + enum CodingKeys: String, CodingKey { + case productionEnvironment = "production_environment" + case deviceToken = "device_token" + case bundleId = "bundle_id" + case teamId = "team_id" + case keyId = "key_id" + case apnsKey = "apns_key" + } + } + + enum CodingKeys: String, CodingKey { + case user + case commandType = "command_type" + case timestamp + case bolusAmount = "bolus_amount" + case target, duration, carbs, protein, fat, overrideName + case scheduledTime = "scheduled_time" + case returnNotification = "return_notification" + } + } + + /// Build the return-notification block from LF credentials in + /// WatchConfig. Returns nil if any required field is missing — + /// matches PushNotificationManager.createReturnNotificationInfo() + /// behavior so Trio falls back to its default ack channel. + private static func makeReturnNotificationInfo(config: WatchConfig) -> TRCPayload.ReturnNotificationInfo? { + guard !config.lfDeviceToken.isEmpty, + !config.lfApnsKey.isEmpty, + !config.lfKeyId.isEmpty, + !config.lfTeamId.isEmpty, + !config.lfBundleId.isEmpty + else { + return nil + } + return TRCPayload.ReturnNotificationInfo( + productionEnvironment: config.lfProductionEnv, + deviceToken: config.lfDeviceToken, + bundleId: config.lfBundleId, + teamId: config.lfTeamId, + keyId: config.lfKeyId, + apnsKey: config.lfApnsKey + ) + } + + private struct APSPayload: Encodable { + let contentAvailable: Int = 1 + let interruptionLevel: String = "time-sensitive" + let alert: String + + enum CodingKeys: String, CodingKey { + case contentAvailable = "content-available" + case interruptionLevel = "interruption-level" + case alert + } + } + + private struct APNSMessage: Encodable { + let aps: APSPayload + let encryptedData: String + + enum CodingKeys: String, CodingKey { + case aps + case encryptedData = "encrypted_data" + } + } + + private static func sendTRCCommand(payload: TRCPayload, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + guard !config.trcSharedSecret.isEmpty, + !config.trcApnsKey.isEmpty, + !config.trcKeyId.isEmpty, + !config.trcTeamId.isEmpty, + !config.trcDeviceToken.isEmpty, + !config.trcBundleId.isEmpty + else { + completion(false, "Missing TRC credentials") + return + } + + // Encrypt payload + guard let encryptedData = encryptPayload(payload, sharedSecret: config.trcSharedSecret) else { + completion(false, "Encryption failed") + return + } + + // Sign JWT (cached for 55 min — see jwtCache above) + guard let jwt = cachedJWT(keyId: config.trcKeyId, teamId: config.trcTeamId, apnsKey: config.trcApnsKey) else { + completion(false, "JWT signing failed") + return + } + + // Build APNS request + let host = config.trcProductionEnv ? "api.push.apple.com" : "api.sandbox.push.apple.com" + guard let url = URL(string: "https://\(host)/3/device/\(config.trcDeviceToken)") else { + completion(false, "Invalid APNS URL") + return + } + + let message = APNSMessage( + aps: APSPayload(alert: "Remote Command: \(payload.commandType)"), + encryptedData: encryptedData + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.setValue("300", forHTTPHeaderField: "apns-expiration") + request.setValue(config.trcBundleId, forHTTPHeaderField: "apns-topic") + request.setValue("alert", forHTTPHeaderField: "apns-push-type") + request.setValue(payload.commandType, forHTTPHeaderField: "apns-collapse-id") + request.httpBody = try? JSONEncoder().encode(message) + + URLSession.shared.dataTask(with: request) { _, response, error in + DispatchQueue.main.async { + if let error = error { + completion(false, error.localizedDescription) + return + } + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + completion(true, nil) + } else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if code == 403 { + // Stale or invalid JWT — drop the cache so the + // next send re-signs. + invalidateJWTCache() + } + completion(false, "APNS error: \(code)") + } + } + }.resume() + } + + // MARK: - CryptoKit AES-GCM Encryption + + private static func encryptPayload(_ payload: T, sharedSecret: String) -> String? { + guard let secretData = sharedSecret.data(using: .utf8) else { return nil } + let keyHash = SHA256.hash(data: secretData) + let symmetricKey = SymmetricKey(data: keyHash) + + guard let payloadData = try? JSONEncoder().encode(payload) else { return nil } + + guard let sealedBox = try? AES.GCM.seal(payloadData, using: symmetricKey) else { return nil } + + // Format: nonce (12 bytes) + ciphertext + tag (16 bytes) — matches CryptoSwift GCM combined mode + guard let combined = sealedBox.combined else { return nil } + return combined.base64EncodedString() + } + + // MARK: - CryptoKit P256 JWT Signing + + /// 55-minute JWT cache. APNS rejects JWTs older than 1 hour, so we + /// regenerate a few minutes before the boundary. Keyed by + /// "keyId:teamId" so distinct credentials (e.g. LF vs remote) don't + /// collide. Mirrors JWTManager.shared on the phone. + private static let jwtTTL: TimeInterval = 55 * 60 + private static var jwtCache: [String: (token: String, expiresAt: Date)] = [:] + private static let jwtCacheQueue = DispatchQueue(label: "com.loopfollow.watch.jwtCache") + + private static func cachedJWT(keyId: String, teamId: String, apnsKey: String) -> String? { + let cacheKey = "\(keyId):\(teamId)" + if let entry = jwtCacheQueue.sync(execute: { jwtCache[cacheKey] }), + entry.expiresAt > Date() + { + return entry.token + } + guard let fresh = signJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { + return nil + } + let expiresAt = Date().addingTimeInterval(jwtTTL) + jwtCacheQueue.sync { jwtCache[cacheKey] = (fresh, expiresAt) } + return fresh + } + + /// Drop cached JWTs. Call when APNS returns 403 so the next send + /// signs fresh credentials. + private static func invalidateJWTCache() { + jwtCacheQueue.sync { jwtCache.removeAll() } + } + + private static func signJWT(keyId: String, teamId: String, apnsKey: String) -> String? { + // Extract raw key data from PEM + let lines = apnsKey.components(separatedBy: "\n") + .filter { !$0.hasPrefix("-----") && !$0.isEmpty } + let base64Key = lines.joined() + guard let keyData = Data(base64Encoded: base64Key) else { return nil } + + guard let privateKey = try? P256.Signing.PrivateKey(derRepresentation: keyData) else { return nil } + + // Header + let header = #"{"alg":"ES256","kid":"\#(keyId)"}"# + // Claims + let claims = #"{"iss":"\#(teamId)","iat":\#(Int(Date().timeIntervalSince1970))}"# + + guard let headerData = header.data(using: .utf8), + let claimsData = claims.data(using: .utf8) + else { return nil } + + let headerB64 = base64URLEncode(headerData) + let claimsB64 = base64URLEncode(claimsData) + let signingInput = "\(headerB64).\(claimsB64)" + + guard let signingData = signingInput.data(using: .utf8), + let signature = try? privateKey.signature(for: signingData) + else { return nil } + + let signatureB64 = base64URLEncode(signature.rawRepresentation) + return "\(signingInput).\(signatureB64)" + } + + private static func base64URLEncode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + // MARK: - Nightscout Treatments POST + + private static func postNightscoutTreatment(body: [String: Any], config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + guard config.nsWriteAuth else { + completion(false, "Nightscout write auth not enabled") + return + } + + // First get JWT token from status endpoint + fetchNightscoutJWT(config: config) { jwt in + guard let jwt = jwt else { + completion(false, "Failed to get Nightscout auth token") + return + } + + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/treatments.json" + + guard let url = components?.url else { + completion(false, "Invalid Nightscout URL") + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: request) { _, response, error in + DispatchQueue.main.async { + if let error = error { + completion(false, error.localizedDescription) + return + } + if let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) { + completion(true, nil) + } else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + completion(false, "Nightscout error: \(code)") + } + } + }.resume() + } + } + + private static func fetchNightscoutJWT(config: WatchConfig, completion: @escaping (String?) -> Void) { + var components = URLComponents(string: config.nsURL) + components?.path = "/api/v1/status.json" + if !config.nsToken.isEmpty { + components?.queryItems = [URLQueryItem(name: "token", value: config.nsToken)] + } + + guard let url = components?.url else { + completion(nil) + return + } + + URLSession.shared.dataTask(with: url) { data, _, error in + // Nightscout's /api/v1/status.json wraps the JWT inside an + // `authorized` object: { "authorized": { "token": "..." } }. + // The previous code looked for a top-level `token` and never + // found it, silently falling back to the raw API secret — + // which doesn't authorize as Bearer JWT on the POST. + guard error == nil, let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let authorized = json["authorized"] as? [String: Any], + let jwt = authorized["token"] as? String + else { + completion(nil) + return + } + completion(jwt) + }.resume() + } +} diff --git a/LoopFollowWatch/WatchSessionManager.swift b/LoopFollowWatch/WatchSessionManager.swift new file mode 100644 index 000000000..8ebd3bca9 --- /dev/null +++ b/LoopFollowWatch/WatchSessionManager.swift @@ -0,0 +1,86 @@ +// LoopFollow +// WatchSessionManager.swift + +import Foundation +import WatchConnectivity + +class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate { + static let shared = WatchSessionManager() + + @Published var config: WatchConfig? + + override private init() { + super.init() + // Load cached config on startup + config = WatchConfig.loadFromDefaults() + } + + func startSession() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + /// Request the iPhone app to re-send its config via all available channels + func requestConfigFromPhone() { + guard WCSession.default.activationState == .activated else { return } + + // sendMessage is the fastest path — works if iPhone app is reachable + if WCSession.default.isReachable { + WCSession.default.sendMessage(["requestConfig": true], replyHandler: { reply in + // iPhone may reply with config directly + if reply["nsURL"] != nil || reply["dexUsername"] != nil { + self.handleReceivedConfig(reply) + } + }, errorHandler: { _ in }) + } + + // transferUserInfo is queued and delivered even if iPhone app isn't running + WCSession.default.transferUserInfo(["requestConfig": true]) + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error: Error?) { + if let error = error { + print("WCSession activation failed: \(error.localizedDescription)") + return + } + + // On activation, check for any previously sent application context + let received = session.receivedApplicationContext + if !received.isEmpty, received["nsURL"] != nil || received["dexUsername"] != nil { + handleReceivedConfig(received) + } + + // Always request config from iPhone — covers fresh install and stale cache + if config == nil { + requestConfigFromPhone() + } + } + + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + handleReceivedConfig(applicationContext) + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + handleReceivedConfig(userInfo) + } + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + handleReceivedConfig(message) + } + + private func handleReceivedConfig(_ dict: [String: Any]) { + // Ignore if this is a requestConfig message from Watch itself + guard dict["requestConfig"] == nil else { return } + // Ignore if it doesn't look like a config (needs at least one data source key) + guard dict["nsURL"] != nil || dict["dexUsername"] != nil else { return } + + let newConfig = WatchConfig(from: dict) + newConfig.saveToDefaults() + DispatchQueue.main.async { + self.config = newConfig + } + } +} diff --git a/LoopFollowWatch/WatchTempTargetView.swift b/LoopFollowWatch/WatchTempTargetView.swift new file mode 100644 index 000000000..f9e53fe22 --- /dev/null +++ b/LoopFollowWatch/WatchTempTargetView.swift @@ -0,0 +1,353 @@ +// LoopFollow +// WatchTempTargetView.swift + +import SwiftUI + +private let tempColor = Color(red: 0.2, green: 0.9, blue: 0.1) + +struct WatchTempTargetView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + @Environment(\.dismiss) private var dismiss + @State private var showConfirm = false + @State private var pendingTarget: Int = 0 + @State private var pendingDuration: Int = 0 + @State private var resultMessage: String? + @State private var isError = false + @State private var showCelebration = false + + var body: some View { + Group { + if let result = resultMessage { + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() + } + CelebrationOverlay(isActive: $showCelebration) + } + } else { + ScrollView { + VStack(spacing: 8) { + if showConfirm { + Text("\(pendingTarget) \(config.units == "mmol/L" ? "mmol/L" : "mg/dL") for \(pendingDuration)m") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(tempColor) + + CrownConfirmView(label: "to set target") { + sendTempTarget() + } + } else { + // Active temp target section (check both devicestatus and treatments) + if let activeTT = activeTempTargetEntry { + Text("Active Temp Target") + .font(.system(size: 12)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("\(Int(activeTT.targetBottom))-\(Int(activeTT.targetTop)) mg/dL" + (activeTT.reason.isEmpty ? "" : " (\(activeTT.reason))")) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background(Color.green.opacity(0.3)) + .cornerRadius(8) + + Button { + cancelTarget() + } label: { + Text("Cancel Temp Target") + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.red.opacity(0.3)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Divider() + } + + Text("Temp Targets") + .font(.system(size: 14, weight: .semibold)) + + Button { + pendingTarget = 160 + pendingDuration = 180 + showConfirm = true + } label: { + Text("Exercise: 160 / 3h") + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(tempColor.opacity(0.55)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Button { + pendingTarget = 80 + pendingDuration = 120 + showConfirm = true + } label: { + Text("Mealtime: 80 / 2h") + .font(.system(size: 15, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(tempColor.opacity(0.55)) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Divider() + + NavigationLink { + CustomTempTargetView(config: config, bgFetcher: bgFetcher) + } label: { + Text("Custom...") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(tempColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + } + } + } + } + + /// Returns the currently active temp target from treatments, or nil if none active. + private var activeTempTargetEntry: TempTargetEntry? { + let now = Date() + return bgFetcher.tempTargetEntries.first { $0.startDate <= now && $0.endDate > now } + } + + private func autoDismiss() { + let delay = showCelebration ? CelebrationOverlay.displayDuration : 3.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + dismiss() + } + } + + private func sendTempTarget() { + WatchRemoteService.sendTempTarget(target: pendingTarget, duration: pendingDuration, config: config) { success, error in + if success { + resultMessage = "Target set!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Temp Target Set", + body: "\(pendingTarget) mg/dL for \(pendingDuration)m" + ) + autoDismiss() + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } + + private func cancelTarget() { + WatchRemoteService.cancelTempTarget(config: config) { success, error in + if success { + resultMessage = "Target cancelled" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Temp Target Cancelled", + body: "Temp target cancel command sent" + ) + autoDismiss() + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} + +// MARK: - Custom Target (pushed via NavigationLink) + +private struct CustomTempTargetView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + @Environment(\.dismiss) private var dismiss + @State private var customTarget: Double = 120 + @State private var customDuration: Double = 60 + @State private var editingField: EditField = .target + @State private var showConfirm = false + @State private var pendingTarget: Int = 0 + @State private var pendingDuration: Int = 0 + @State private var resultMessage: String? + @State private var isError = false + @State private var showCelebration = false + + enum EditField { + case target, duration + } + + private var crownBinding: Binding { + Binding( + get: { + guard !showConfirm else { return 0 } + switch editingField { + case .target: return customTarget + case .duration: return customDuration + } + }, + set: { newValue in + guard !showConfirm else { return } + switch editingField { + case .target: customTarget = newValue + case .duration: customDuration = newValue + } + } + ) + } + + private var crownRange: ClosedRange { + guard !showConfirm else { return 0 ... 1 } + switch editingField { + case .target: return 60 ... 300 + case .duration: return 5 ... 480 + } + } + + private var crownStep: Double { + switch editingField { + case .target: return 5 + case .duration: return 5 + } + } + + var body: some View { + Group { + if let result = resultMessage { + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() + } + CelebrationOverlay(isActive: $showCelebration) + } + } else { + ScrollView { + VStack(spacing: 8) { + if showConfirm { + Text("\(pendingTarget) \(config.units == "mmol/L" ? "mmol/L" : "mg/dL") for \(pendingDuration)m") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(tempColor) + + CrownConfirmView(label: "to set target") { + sendTempTarget() + } + } else { + Text("Custom Target") + .font(.system(size: 14, weight: .semibold)) + + Button { + editingField = .target + } label: { + HStack { + Text("Target:") + .font(.system(size: 12)) + .foregroundColor(.primary) + Spacer() + Text(config.units == "mmol/L" + ? String(format: "%.1f", customTarget * 0.0555) + : "\(Int(customTarget))") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(editingField == .target ? tempColor : .primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(editingField == .target ? tempColor.opacity(0.15) : Color.clear) + .cornerRadius(6) + } + .buttonStyle(.plain) + + Button { + editingField = .duration + } label: { + HStack { + Text("Duration:") + .font(.system(size: 12)) + .foregroundColor(.primary) + Spacer() + Text("\(Int(customDuration))m") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(editingField == .duration ? tempColor : .primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(editingField == .duration ? tempColor.opacity(0.15) : Color.clear) + .cornerRadius(6) + } + .buttonStyle(.plain) + + Text("Tap a field, then scroll crown") + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Button { + pendingTarget = Int(customTarget) + pendingDuration = Int(customDuration) + showConfirm = true + } label: { + Text("Set") + .font(.system(size: 15, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(tempColor) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + } + } + } + } + .modifier(CrownRotationModifier( + isActive: !showConfirm && resultMessage == nil, + value: crownBinding, + from: crownRange.lowerBound, + through: crownRange.upperBound, + by: crownStep, + sensitivity: .medium + )) + } + + private func autoDismiss() { + let delay = showCelebration ? CelebrationOverlay.displayDuration : 3.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + dismiss() + } + } + + private func sendTempTarget() { + WatchRemoteService.sendTempTarget(target: pendingTarget, duration: pendingDuration, config: config) { success, error in + if success { + resultMessage = "Target set!" + showCelebration = CelebrationOverlay.shouldCelebrate() + WatchRemoteService.postLocalNotification( + title: "Temp Target Set", + body: "\(pendingTarget) mg/dL for \(pendingDuration)m" + ) + autoDismiss() + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} diff --git a/LoopFollowWidgets/ActionShortcutWidgets.swift b/LoopFollowWidgets/ActionShortcutWidgets.swift new file mode 100644 index 000000000..a301c4ce2 --- /dev/null +++ b/LoopFollowWidgets/ActionShortcutWidgets.swift @@ -0,0 +1,110 @@ +// LoopFollow +// ActionShortcutWidgets.swift + +import SwiftUI +import WidgetKit + +// MARK: - Shared Timeline (static, never changes) + +struct ActionEntry: TimelineEntry { + let date: Date +} + +struct ActionTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> ActionEntry { + ActionEntry(date: .now) + } + + func getSnapshot(in _: Context, completion: @escaping (ActionEntry) -> Void) { + completion(ActionEntry(date: .now)) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [ActionEntry(date: .now)], policy: .never)) + } +} + +// MARK: - Shared View + +struct ActionShortcutView: View { + let systemImage: String + var color: Color = .primary + + var body: some View { + ZStack { + AccessoryWidgetBackground() + Image(systemName: systemImage) + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(color) + .widgetAccentable() + } + } +} + +// MARK: - Bolus Shortcut + +struct BolusShortcutWidget: Widget { + let kind = "BolusShortcut" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ActionTimelineProvider()) { _ in + ActionShortcutView(systemImage: "drop.fill", color: .blue) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "loopfollow://bolus")) + } + .configurationDisplayName("Bolus") + .description("Quick access to bolus entry.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Meal Shortcut + +struct MealShortcutWidget: Widget { + let kind = "MealShortcut" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ActionTimelineProvider()) { _ in + ActionShortcutView(systemImage: "fork.knife", color: .yellow) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "loopfollow://meal")) + } + .configurationDisplayName("Meal") + .description("Quick access to meal entry.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Override Shortcut + +struct OverrideShortcutWidget: Widget { + let kind = "OverrideShortcut" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ActionTimelineProvider()) { _ in + ActionShortcutView(systemImage: "bolt.fill", color: .purple) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "loopfollow://override")) + } + .configurationDisplayName("Override") + .description("Quick access to override selection.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Temp Target Shortcut + +struct TempTargetShortcutWidget: Widget { + let kind = "TempTargetShortcut" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ActionTimelineProvider()) { _ in + ActionShortcutView(systemImage: "target", color: Color(red: 0.2, green: 0.9, blue: 0.1)) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "loopfollow://temptarget")) + } + .configurationDisplayName("Temp Target") + .description("Quick access to temp target selection.") + .supportedFamilies([.accessoryCircular]) + } +} diff --git a/LoopFollowWidgets/BGDynamicColor.swift b/LoopFollowWidgets/BGDynamicColor.swift new file mode 100644 index 000000000..dc9fc02ba --- /dev/null +++ b/LoopFollowWidgets/BGDynamicColor.swift @@ -0,0 +1,31 @@ +// LoopFollow +// BGDynamicColor.swift + +import SwiftUI + +/// Returns a hue-based color for a given BG value in mg/dL. +/// Low (≤55) = red, target (100) = green, high (≥220) = purple. +func bgDynamicColor(_ bg: Double) -> Color { + let low = 55.0 + let target = 100.0 + let high = 220.0 + + let redHue: CGFloat = 0.0 / 360.0 + let greenHue: CGFloat = 120.0 / 360.0 + let purpleHue: CGFloat = 270.0 / 360.0 + + let hue: CGFloat + if bg <= low { + hue = redHue + } else if bg >= high { + hue = purpleHue + } else if bg <= target { + let ratio = CGFloat((bg - low) / (target - low)) + hue = redHue + ratio * (greenHue - redHue) + } else { + let ratio = CGFloat((bg - target) / (high - target)) + hue = greenHue + ratio * (purpleHue - greenHue) + } + + return Color(hue: Double(hue), saturation: 0.6, brightness: 0.9) +} diff --git a/LoopFollowWidgets/BGLiveActivity.swift b/LoopFollowWidgets/BGLiveActivity.swift new file mode 100644 index 000000000..7ae0ee824 --- /dev/null +++ b/LoopFollowWidgets/BGLiveActivity.swift @@ -0,0 +1,103 @@ +// LoopFollow +// BGLiveActivity.swift + +#if os(iOS) + import ActivityKit + import SwiftUI + import WidgetKit + + // MARK: - Activity Attributes + + struct BGLiveActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + let bgValue: Int + let direction: String + let delta: Int? + let bgTimestamp: Date + let iob: Double? + let cob: Double? + let basalRate: Double? + let scheduledBasal: Double? + let history: [WidgetBGPoint] + let units: String + let updatedAt: Date + + init(from data: WidgetData) { + bgValue = data.bgValue + direction = data.direction + delta = data.delta + bgTimestamp = data.bgTimestamp + iob = data.iob + cob = data.cob + basalRate = data.basalRate + scheduledBasal = data.scheduledBasal + history = data.history + units = data.units + updatedAt = data.updatedAt + } + } + } + + // MARK: - Live Activity Configuration + + struct BGLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: BGLiveActivityAttributes.self) { context in + let data = widgetData(from: context.state) + BGComplicationContent( + data: data, + displayDate: Date(), + useColor: true + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .activityBackgroundTint(.black.opacity(0.7)) + + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.center) { + let data = widgetData(from: context.state) + BGComplicationContent( + data: data, + displayDate: Date(), + useColor: true + ) + } + } compactLeading: { + Text("\(context.state.bgValue)") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundColor(bgColor(for: context.state.bgValue)) + } compactTrailing: { + Text(context.state.direction) + .font(.system(size: 12)) + } minimal: { + Text("\(context.state.bgValue)") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(bgColor(for: context.state.bgValue)) + } + } + } + + private func widgetData(from state: BGLiveActivityAttributes.ContentState) -> WidgetData { + WidgetData( + bgValue: state.bgValue, + direction: state.direction, + delta: state.delta, + bgTimestamp: state.bgTimestamp, + iob: state.iob, + cob: state.cob, + basalRate: state.basalRate, + scheduledBasal: state.scheduledBasal, + history: state.history, + units: state.units, + updatedAt: state.updatedAt + ) + } + + private func bgColor(for bg: Int) -> Color { + if bg < 70 || bg > 180 { return .red } + if bg < 80 || bg > 170 { return .yellow } + return .green + } + } +#endif diff --git a/LoopFollowWidgets/BGTimelineProvider.swift b/LoopFollowWidgets/BGTimelineProvider.swift new file mode 100644 index 000000000..342376b9e --- /dev/null +++ b/LoopFollowWidgets/BGTimelineProvider.swift @@ -0,0 +1,57 @@ +// LoopFollow +// BGTimelineProvider.swift + +import SwiftUI +import WidgetKit + +struct BGEntry: TimelineEntry { + let date: Date + let data: WidgetData? + /// The reference "now" for staleness calculation. Each entry in the batch + /// carries the *display time* so the staleness text is correct without a reload. + let displayDate: Date +} + +struct BGTimelineProvider: TimelineProvider { + // MARK: - Required protocol + + func placeholder(in _: Context) -> BGEntry { + BGEntry(date: .now, data: nil, displayDate: .now) + } + + func getSnapshot(in _: Context, completion: @escaping (BGEntry) -> Void) { + let entry = BGEntry(date: .now, data: WidgetData.load(), displayDate: .now) + completion(entry) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + // Try to fetch fresh BG directly from Nightscout. This runs inside + // the widget extension process — independent of the watch app and its + // background task budget. The system calls getTimeline every ~5 min + // for an active complication, so this is our most reliable update path. + WidgetNightscoutFetcher.fetch { result in + let data: WidgetData? + switch result { + case let .updated(d): data = d + case let .unchanged(d): data = d + case let .failed(d): data = d + } + + let now = Date() + + // Generate entries every minute for the next hour. + // Each entry carries a different `displayDate` so the staleness text + // advances correctly without burning a reload. + var entries: [BGEntry] = [] + for i in 0 ..< 60 { + let entryDate = now.addingTimeInterval(Double(i) * 60) + entries.append(BGEntry(date: entryDate, data: data, displayDate: entryDate)) + } + + // Ask for a fresh timeline in 5 minutes. + let expiry = now.addingTimeInterval(5 * 60) + let timeline = Timeline(entries: entries, policy: .after(expiry)) + completion(timeline) + } + } +} diff --git a/LoopFollowWidgets/CircularComplicationView.swift b/LoopFollowWidgets/CircularComplicationView.swift new file mode 100644 index 000000000..a711614e0 --- /dev/null +++ b/LoopFollowWidgets/CircularComplicationView.swift @@ -0,0 +1,91 @@ +// LoopFollow +// CircularComplicationView.swift + +// +// Round complication for modular watch faces (accessoryCircular). +// Layout: staleness on top, BG center, delta + trend below. +// No color — transparent background, white/primary text. +// When reading is >=16 min stale, all text turns gray with a strikethrough line. + +import SwiftUI +import WidgetKit + +struct CircularComplicationView: View { + let entry: BGEntry + + var body: some View { + if let data = entry.data { + let isStale = entry.displayDate.timeIntervalSince(data.bgTimestamp) >= 16 * 60 + + ZStack { + AccessoryWidgetBackground() + + VStack(spacing: -4) { + // Staleness — top + Text(stalenessText(data, displayDate: entry.displayDate)) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(isStale ? .secondary : .primary) + .lineLimit(1) + + // BG value — center, biggest + Text(bgText(data)) + .font(.system(size: 22, weight: .medium)) + .foregroundColor(isStale ? .secondary : bgDynamicColor(Double(data.bgValue))) + .minimumScaleFactor(0.6) + .lineLimit(1) + .widgetAccentable() + .overlay { + if isStale { + Rectangle() + .frame(height: 1.5) + .foregroundColor(.secondary) + } + } + + // Trend arrow + delta — bottom + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(data.direction) + .baselineOffset(-1) + if let d = data.delta { + Text(deltaText(d, units: data.units)) + } + } + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(isStale ? .secondary : .primary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + } else { + ZStack { + AccessoryWidgetBackground() + Text("--") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Helpers + + private func bgText(_ data: WidgetData) -> String { + if data.units == "mmol/L" { + return String(format: "%.1f", Double(data.bgValue) * 0.0555) + } + return "\(data.bgValue)" + } + + private func deltaText(_ delta: Int, units: String) -> String { + if units == "mmol/L" { + let mmol = Double(delta) * 0.0555 + return String(format: "%+.1f", mmol) + } + return String(format: "%+d", delta) + } + + private func stalenessText(_ data: WidgetData, displayDate: Date) -> String { + let minutes = Int(displayDate.timeIntervalSince(data.bgTimestamp) / 60) + if minutes < 1 { return "now" } + return "\(minutes)m" + } +} diff --git a/LoopFollowWidgets/Info.plist b/LoopFollowWidgets/Info.plist new file mode 100644 index 000000000..f6fa393cd --- /dev/null +++ b/LoopFollowWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LoopFollow Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWidgets/LoopFollowWidgets.entitlements b/LoopFollowWidgets/LoopFollowWidgets.entitlements new file mode 100644 index 000000000..b14d8a63c --- /dev/null +++ b/LoopFollowWidgets/LoopFollowWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.loopfollow.shared + + + diff --git a/LoopFollowWidgets/LoopFollowWidgets.swift b/LoopFollowWidgets/LoopFollowWidgets.swift new file mode 100644 index 000000000..721e89af7 --- /dev/null +++ b/LoopFollowWidgets/LoopFollowWidgets.swift @@ -0,0 +1,50 @@ +// LoopFollow +// LoopFollowWidgets.swift + +import SwiftUI +import WidgetKit + +struct BGComplicationEntryView: View { + let entry: BGEntry + @Environment(\.widgetFamily) var family + + var body: some View { + switch family { + case .accessoryCircular: + CircularComplicationView(entry: entry) + case .accessoryRectangular: + RectangularComplicationView(entry: entry) + default: + RectangularComplicationView(entry: entry) + } + } +} + +struct BGComplicationWidget: Widget { + let kind: String = "BGComplication" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: BGTimelineProvider()) { entry in + BGComplicationEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "loopfollow://open")) + } + .configurationDisplayName("BG Monitor") + .description("Blood glucose with trend and stats.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular]) + } +} + +@main +struct LoopFollowWidgetBundle: WidgetBundle { + var body: some Widget { + BGComplicationWidget() + BolusShortcutWidget() + MealShortcutWidget() + OverrideShortcutWidget() + TempTargetShortcutWidget() + #if os(iOS) + BGLiveActivityWidget() + #endif + } +} diff --git a/LoopFollowWidgets/RectangularComplicationView.swift b/LoopFollowWidgets/RectangularComplicationView.swift new file mode 100644 index 000000000..7a0c608a3 --- /dev/null +++ b/LoopFollowWidgets/RectangularComplicationView.swift @@ -0,0 +1,374 @@ +// LoopFollow +// RectangularComplicationView.swift + +import SwiftUI +import WidgetKit + +// MARK: - Public Complication View (used by Widget + future Live Activity) + +/// Full-width sparkline with text stats overlaid on the left. +/// Graph fades in aggressively; line thickens and brightens from left to right. +struct BGComplicationContent: View { + let data: WidgetData + let displayDate: Date + let useColor: Bool + + var body: some View { + ZStack(alignment: .leading) { + // Full-width sparkline — aggressive left fade + SparklineView( + history: data.history, + displayDate: displayDate + ) + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .clear, location: 0.39), + .init(color: .white, location: 0.80), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + + // Text overlay on the left + StatsPanel(data: data, displayDate: displayDate) + .padding(.leading, 0) + } + .padding(.horizontal, 0) + } +} + +/// Thin wrapper that reads `BGEntry` and the widget rendering mode, then delegates +/// to `BGComplicationContent`. +struct RectangularComplicationView: View { + let entry: BGEntry + @Environment(\.widgetRenderingMode) var renderingMode + + private var useColor: Bool { + renderingMode == .fullColor + } + + var body: some View { + if let data = entry.data { + BGComplicationContent( + data: data, + displayDate: entry.displayDate, + useColor: useColor + ) + } else { + Text("No Data") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Sparkline Graph (progressive line with Catmull-Rom curves) + +private struct SparklineView: View { + let history: [WidgetBGPoint] + let displayDate: Date + + /// Only the points that are actually visible given the left-fade mask. + /// The mask is fully transparent for the first 25% and fades in through 65%, + /// so data older than ~2h is effectively invisible. Use last 2h for Y-axis range. + private var visibleHistory: [WidgetBGPoint] { + let cutoff = displayDate.addingTimeInterval(-2 * 3600) + return history.filter { $0.timestamp >= cutoff } + } + + /// Compute Y-axis range from visible data only, with 2-point padding. + private var dataRange: (min: Double, max: Double) { + guard !visibleHistory.isEmpty else { return (40, 300) } + let values = visibleHistory.map { Double($0.value) } + let lo = values.min()! + let hi = values.max()! + return (lo - 2, hi + 2) + } + + /// Generate up to 4 "nice" ticks within the visible range. + private var yTicks: [Int] { + let range = dataRange + let lo = Int(ceil(range.min)) + let hi = Int(floor(range.max)) + let span = hi - lo + guard span > 0 else { return [] } + + let step: Int + if span <= 20 { step = 5 } + else if span <= 50 { step = 10 } + else if span <= 100 { step = 20 } + else { step = 40 } + + let start = lo + (step - (lo % step)) % step + var ticks: [Int] = [] + var v = start + while v <= hi && ticks.count < 4 { + ticks.append(v) + v += step + } + return ticks + } + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let topInset: CGFloat = 6 // room for top y-axis label + let bottomInset: CGFloat = 4 // room for bottom y-axis label + let chartH = h - topInset - bottomInset + // Keep sparkline clear of y-axis labels. Labels sit at + // x = w - 16 in a 28pt trailing-aligned frame, so a + // 3-digit label like "140" (~17pt wide at 10pt medium) + // extends left to ~w - 19. A 22pt inset gives ~3pt + // clearance without a visible dead zone. + let rightInset: CGFloat = 22 + let sparkW = w - rightInset + let sorted = history.sorted { $0.timestamp < $1.timestamp } + // End the x-axis at the most recent reading (not displayDate) + // so the sparkline's rightmost point always lands at sparkW. + // Otherwise staleness (~5–10m typical) adds a visible gap on + // the right — ~4% of sparkW per 7 minutes of staleness. + let endTime = sorted.last?.timestamp ?? displayDate + let threeHoursAgo = endTime.addingTimeInterval(-3 * 3600) + let yMin = dataRange.min + let yMax = dataRange.max + + // Convert BG points to screen coordinates (within sparkline area) + let screenPoints: [CGPoint] = sorted.map { point in + CGPoint( + x: xPosition(for: point.timestamp, start: threeHoursAgo, end: endTime, width: sparkW), + y: topInset + yPosition(for: Double(point.value), yMin: yMin, yMax: yMax, height: chartH) + ) + } + + ZStack { + // Dotted horizontal reference lines + Y-axis labels + ForEach(yTicks, id: \.self) { value in + let y = topInset + yPosition(for: Double(value), yMin: yMin, yMax: yMax, height: chartH) + + Path { path in + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: w, y: y)) + } + .stroke(style: StrokeStyle(lineWidth: 0.3, dash: [1, 3])) + .foregroundColor(.secondary.opacity(0.15)) + + Text("\(value)") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary.opacity(0.6)) + .frame(width: 28, alignment: .trailing) + .position(x: w - 16, y: y) + } + + if screenPoints.count >= 2 { + Group { + // Per-segment fill + stroke — each colored by midpoint BG value + ForEach(0 ..< (screenPoints.count - 1), id: \.self) { i in + let t = Double(i) / Double(max(screenPoints.count - 2, 1)) + let lineWidth = 0.3 + t * 1.7 + let opacity = min(t * 1.4, 1.0) + let midBG = Double(sorted[i].value + sorted[i + 1].value) / 2.0 + let segColor = bgDynamicColor(midBG) + + // Fill slice under this segment + buildSegmentFill(points: screenPoints, index: i, height: topInset + chartH) + .fill( + LinearGradient( + colors: [segColor.opacity(0.60), segColor.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + + // Stroke with progressive width + opacity + buildSingleSegment(points: screenPoints, index: i) + .stroke( + segColor.opacity(opacity), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .round) + ) + } + } + .widgetAccentable() + } + } + } + } + + // MARK: - Path builders + + /// Builds a single Catmull-Rom curve segment from points[index] to points[index+1]. + private func buildSingleSegment(points: [CGPoint], index: Int) -> Path { + Path { path in + let i = index + let p0 = points[max(i - 1, 0)] + let p1 = points[i] + let p2 = points[min(i + 1, points.count - 1)] + let p3 = points[min(i + 2, points.count - 1)] + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) / 6.0, + y: p1.y + (p2.y - p0.y) / 6.0 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) / 6.0, + y: p2.y - (p3.y - p1.y) / 6.0 + ) + + path.move(to: p1) + path.addCurve(to: p2, control1: cp1, control2: cp2) + } + } + + /// Builds a single segment's fill slice: Catmull-Rom curve closed down to the bottom edge. + private func buildSegmentFill(points: [CGPoint], index: Int, height: Double) -> Path { + Path { path in + let i = index + let p0 = points[max(i - 1, 0)] + let p1 = points[i] + let p2 = points[min(i + 1, points.count - 1)] + let p3 = points[min(i + 2, points.count - 1)] + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) / 6.0, + y: p1.y + (p2.y - p0.y) / 6.0 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) / 6.0, + y: p2.y - (p3.y - p1.y) / 6.0 + ) + + path.move(to: p1) + path.addCurve(to: p2, control1: cp1, control2: cp2) + path.addLine(to: CGPoint(x: p2.x, y: height)) + path.addLine(to: CGPoint(x: p1.x, y: height)) + path.closeSubpath() + } + } + + /// Builds the filled area: Catmull-Rom curve closed down to the bottom edge. + private func buildFillPath(points: [CGPoint], height: Double) -> Path { + Path { path in + guard let first = points.first, let last = points.last else { return } + path.move(to: first) + + if points.count == 2 { + path.addLine(to: last) + } else { + for i in 0 ..< (points.count - 1) { + let p0 = points[max(i - 1, 0)] + let p1 = points[i] + let p2 = points[min(i + 1, points.count - 1)] + let p3 = points[min(i + 2, points.count - 1)] + + let cp1 = CGPoint( + x: p1.x + (p2.x - p0.x) / 6.0, + y: p1.y + (p2.y - p0.y) / 6.0 + ) + let cp2 = CGPoint( + x: p2.x - (p3.x - p1.x) / 6.0, + y: p2.y - (p3.y - p1.y) / 6.0 + ) + + path.addCurve(to: p2, control1: cp1, control2: cp2) + } + } + + path.addLine(to: CGPoint(x: last.x, y: height)) + path.addLine(to: CGPoint(x: first.x, y: height)) + path.closeSubpath() + } + } + + // MARK: - Coordinate helpers + + private func xPosition(for date: Date, start: Date, end: Date, width: Double) -> Double { + let total = end.timeIntervalSince(start) + guard total > 0 else { return 0 } + let elapsed = date.timeIntervalSince(start) + return max(0, min(width, (elapsed / total) * width)) + } + + private func yPosition(for value: Double, yMin: Double, yMax: Double, height: Double) -> Double { + guard yMax > yMin else { return height / 2 } + let clamped = min(max(value, yMin), yMax) + let fraction = (clamped - yMin) / (yMax - yMin) + return height * (1 - fraction) + } +} + +// MARK: - Stats Panel (overlays left side of graph) + +private struct StatsPanel: View { + let data: WidgetData + let displayDate: Date + + private var isStale: Bool { + displayDate.timeIntervalSince(data.bgTimestamp) >= 16 * 60 + } + + var body: some View { + HStack(alignment: .center, spacing: 3) { + // Big BG value + Text(bgText) + .font(.system(size: 54, weight: .regular)) + .foregroundColor(isStale ? .secondary : bgDynamicColor(Double(data.bgValue))) + .minimumScaleFactor(0.6) + .lineLimit(1) + .widgetAccentable() + .overlay { + if isStale { + Rectangle() + .frame(height: 2) + .foregroundColor(.secondary) + } + } + + // Trend arrow + delta + staleness — vertically centered on BG number + VStack(alignment: .leading, spacing: -3) { + Text(data.direction) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(isStale ? .secondary : .primary) + + if let d = data.delta { + Text(deltaText(d)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(isStale ? .secondary : .primary) + .offset(y: -1.5) + } + + Text(stalenessText) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(isStale ? .secondary : .primary) + } + .fixedSize() + } + } + + private var bgText: String { + if data.units == "mmol/L" { + return String(format: "%.1f", Double(data.bgValue) * 0.0555) + } + return "\(data.bgValue)" + } + + private func deltaText(_ delta: Int) -> String { + if data.units == "mmol/L" { + let mmol = Double(delta) * 0.0555 + return String(format: "%+.1f", mmol) + } + return String(format: "%+d", delta) + } + + private var stalenessText: String { + let minutes = Int(displayDate.timeIntervalSince(data.bgTimestamp) / 60) + if minutes < 1 { return "now" } + return "\(minutes)m" + } + + private var stalenessColor: Color { + return .primary + } +} diff --git a/LoopFollowWidgets/WidgetData.swift b/LoopFollowWidgets/WidgetData.swift new file mode 100644 index 000000000..332262b50 --- /dev/null +++ b/LoopFollowWidgets/WidgetData.swift @@ -0,0 +1,45 @@ +// LoopFollow +// WidgetData.swift + +import Foundation + +struct WidgetBGPoint: Codable, Hashable { + let value: Int // mg/dL + let timestamp: Date +} + +struct WidgetData: Codable { + let bgValue: Int // current BG in mg/dL + let direction: String // trend arrow + let delta: Int? // signed delta from previous reading + let bgTimestamp: Date // when the current BG was recorded + let iob: Double? + let cob: Double? + let basalRate: Double? // current enacted rate + let scheduledBasal: Double? // profile-based rate + let history: [WidgetBGPoint] // last ~3 hours + let units: String // "mg/dL" or "mmol/L" + let updatedAt: Date // when this snapshot was written + + private static let storageKey = "widgetData" + + /// App Group shared between the watch app and widget extension. + /// Both targets must have this App Group in their entitlements. + static let appGroupID = "group.loopfollow.shared" + + private static var sharedDefaults: UserDefaults { + UserDefaults(suiteName: appGroupID) ?? .standard + } + + func save() { + guard let data = try? JSONEncoder().encode(self) else { return } + Self.sharedDefaults.set(data, forKey: Self.storageKey) + } + + static func load() -> WidgetData? { + guard let data = sharedDefaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) + else { return nil } + return decoded + } +} diff --git a/LoopFollowWidgets/WidgetNightscoutFetcher.swift b/LoopFollowWidgets/WidgetNightscoutFetcher.swift new file mode 100644 index 000000000..35fcbd33d --- /dev/null +++ b/LoopFollowWidgets/WidgetNightscoutFetcher.swift @@ -0,0 +1,158 @@ +// LoopFollow +// WidgetNightscoutFetcher.swift + +import Foundation + +enum WidgetNightscoutFetcher { + /// Result of a widget-side fetch attempt. + enum FetchResult { + case updated(WidgetData) // new reading(s) merged into cache + case unchanged(WidgetData) // cache was already current + case failed(WidgetData?) // network/parse error; returns cache if available + } + + /// Fetch the latest 3 entries from Nightscout, merge into cached WidgetData, + /// and return the result. Completes on an arbitrary queue. + static func fetch(timeout: TimeInterval = 4, completion: @escaping (FetchResult) -> Void) { + let shared = UserDefaults(suiteName: WidgetData.appGroupID) ?? .standard + let nsURL = shared.string(forKey: "nsURL") ?? "" + let nsToken = shared.string(forKey: "nsToken") ?? "" + + guard !nsURL.isEmpty else { + completion(.failed(WidgetData.load())) + return + } + + var components = URLComponents(string: nsURL) + components?.path = "/api/v1/entries.json" + + var queryItems = [URLQueryItem]() + if !nsToken.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: nsToken)) + } + queryItems.append(URLQueryItem(name: "count", value: "3")) + queryItems.append(URLQueryItem(name: "find[type][$ne]", value: "cal")) + components?.queryItems = queryItems + + guard let url = components?.url else { + completion(.failed(WidgetData.load())) + return + } + + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + + URLSession.shared.dataTask(with: request) { data, _, error in + if error != nil { + completion(.failed(WidgetData.load())) + return + } + + guard let data = data else { + completion(.failed(WidgetData.load())) + return + } + + let result = mergeResponse(data: data) + completion(result) + }.resume() + } + + // MARK: - Parsing + merge + + private struct NSEntry: Decodable { + var sgv: Double? + var mbg: Double? + var glucose: Double? + var date: TimeInterval // epoch millis + var direction: String? + + var bgValue: Int? { + if let sgv = sgv { return Int(sgv.rounded()) } + if let mbg = mbg { return Int(mbg.rounded()) } + if let glucose = glucose { return Int(glucose.rounded()) } + return nil + } + + var timestamp: Date { + Date(timeIntervalSince1970: date / 1000) + } + } + + private static let directionMap: [String: String] = [ + "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", + "Flat": "→", "FortyFiveDown": "↘", "SingleDown": "↓", + "DoubleDown": "↓↓", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", + "NONE": "-", "": "-", + ] + + private static func mergeResponse(data: Data) -> FetchResult { + let cache = WidgetData.load() + + guard let entries = try? JSONDecoder().decode([NSEntry].self, from: data), + let latest = entries.first, + let latestBG = latest.bgValue + else { + return .failed(cache) + } + + let latestTimestamp = latest.timestamp + + // If cache already has this reading (or newer), nothing to do. + if let cache = cache, cache.bgTimestamp >= latestTimestamp { + return .unchanged(cache) + } + + // Compute delta from the 2nd entry if available, else from cache. + let delta: Int? + if entries.count >= 2, let prevBG = entries[1].bgValue { + delta = latestBG - prevBG + } else if let cache = cache { + delta = latestBG - cache.bgValue + } else { + delta = nil + } + + let direction = directionMap[latest.direction ?? ""] ?? "-" + + // Build new points from fetched entries. + let newPoints = entries.compactMap { entry -> WidgetBGPoint? in + guard let bg = entry.bgValue else { return nil } + return WidgetBGPoint(value: bg, timestamp: entry.timestamp) + } + + // Merge with cached history: new points + existing, dedupe by timestamp, + // trim to 3.5 hours. + let cutoff = Date().addingTimeInterval(-3.5 * 3600) + let existingHistory = cache?.history ?? [] + + // Combine, removing duplicates (same timestamp within 1s). + var seen = Set() // epoch seconds as dedup key + var merged: [WidgetBGPoint] = [] + for point in newPoints + existingHistory { + let key = Int(point.timestamp.timeIntervalSince1970) + if point.timestamp > cutoff && seen.insert(key).inserted { + merged.append(point) + } + } + merged.sort { $0.timestamp > $1.timestamp } // newest first + + let updated = WidgetData( + bgValue: latestBG, + direction: direction, + delta: delta, + bgTimestamp: latestTimestamp, + iob: cache?.iob, + cob: cache?.cob, + basalRate: cache?.basalRate, + scheduledBasal: cache?.scheduledBasal, + history: merged, + units: cache?.units ?? "mg/dL", + updatedAt: Date() + ) + updated.save() + + return .updated(updated) + } +}