From cb1f3c3041d718307b8887781f2dbe5a82c1fc4a Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Sun, 3 May 2026 13:09:13 +0000 Subject: [PATCH 1/7] Add Apple Watch companion app on clean upstream dev base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch app with BG chart, remote controls (bolus, meal, override, temp target), and Nightscout/Dexcom Share data fetching. APNS push uses alert type with time-sensitive delivery for reliable command delivery. Isolated watch-only changes on top of latest upstream dev — no unrelated iOS feature modifications. --- LoopFollow.xcodeproj/project.pbxproj | 153 ++++ .../xcschemes/LoopFollowWatch.xcscheme | 79 ++ LoopFollow/Application/AppDelegate.swift | 3 + .../Controllers/Nightscout/BGData.swift | 2 + .../NightscoutSettingsViewModel.swift | 2 + .../Settings/DexcomSettingsViewModel.swift | 3 + .../Settings/UnitsConfigurationView.swift | 3 + LoopFollow/Watch/PhoneSessionManager.swift | 109 +++ .../AppIcon.appiconset/1024.png | Bin 0 -> 56954 bytes .../AppIcon.appiconset/Contents.json | 14 + LoopFollowWatch/Assets.xcassets/Contents.json | 6 + LoopFollowWatch/BGChartView.swift | 124 +++ LoopFollowWatch/BGFetcher.swift | 742 ++++++++++++++++++ LoopFollowWatch/BGReading.swift | 68 ++ LoopFollowWatch/ContentView.swift | 252 ++++++ LoopFollowWatch/CrownConfirmView.swift | 121 +++ LoopFollowWatch/Info.plist | 28 + LoopFollowWatch/LoopFollowWatchApp.swift | 47 ++ LoopFollowWatch/LoopStatus.swift | 26 + LoopFollowWatch/OverridePreset.swift | 12 + LoopFollowWatch/RemoteControlView.swift | 70 ++ LoopFollowWatch/WatchBolusView.swift | 93 +++ LoopFollowWatch/WatchConfig.swift | 115 +++ LoopFollowWatch/WatchMealView.swift | 83 ++ LoopFollowWatch/WatchOverrideView.swift | 108 +++ LoopFollowWatch/WatchRemoteService.swift | 368 +++++++++ LoopFollowWatch/WatchSessionManager.swift | 86 ++ LoopFollowWatch/WatchTempTargetView.swift | 208 +++++ 28 files changed, 2925 insertions(+) create mode 100644 LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme create mode 100644 LoopFollow/Watch/PhoneSessionManager.swift create mode 100644 LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 LoopFollowWatch/Assets.xcassets/Contents.json create mode 100644 LoopFollowWatch/BGChartView.swift create mode 100644 LoopFollowWatch/BGFetcher.swift create mode 100644 LoopFollowWatch/BGReading.swift create mode 100644 LoopFollowWatch/ContentView.swift create mode 100644 LoopFollowWatch/CrownConfirmView.swift create mode 100644 LoopFollowWatch/Info.plist create mode 100644 LoopFollowWatch/LoopFollowWatchApp.swift create mode 100644 LoopFollowWatch/LoopStatus.swift create mode 100644 LoopFollowWatch/OverridePreset.swift create mode 100644 LoopFollowWatch/RemoteControlView.swift create mode 100644 LoopFollowWatch/WatchBolusView.swift create mode 100644 LoopFollowWatch/WatchConfig.swift create mode 100644 LoopFollowWatch/WatchMealView.swift create mode 100644 LoopFollowWatch/WatchOverrideView.swift create mode 100644 LoopFollowWatch/WatchRemoteService.swift create mode 100644 LoopFollowWatch/WatchSessionManager.swift create mode 100644 LoopFollowWatch/WatchTempTargetView.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index c76e57915..2825b80d8 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -431,6 +431,8 @@ FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -441,6 +443,13 @@ remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289; remoteInfo = LoopFollowLAExtensionExtension; }; + BB0100000000000D000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = BB01000000000003000000AA; + remoteInfo = LoopFollowWatch; + }; DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -462,6 +471,17 @@ 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; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -893,12 +913,25 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; FCFEEC9F2488157B00402A7F /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.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 = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BB01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = BB01000000000003000000AA /* LoopFollowWatch */; + }; +/* 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 = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -928,6 +961,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000006000000AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1007,6 +1047,14 @@ path = Pods; sourceTree = ""; }; + BB01000000000010000000AA /* Watch */ = { + isa = PBXGroup; + children = ( + BB0100000000000E000000AA /* PhoneSessionManager.swift */, + ); + path = Watch; + sourceTree = ""; + }; DD02475A2DB2E8CE00FCADF6 /* AlarmCondition */ = { isa = PBXGroup; children = ( @@ -1625,6 +1673,7 @@ DDC7E5142DBCE1B900EB1127 /* Snoozer */, DDC7E5CD2DC6637800EB1127 /* Storage */, DDEF503D2D32753A00999A5D /* Task */, + BB01000000000010000000AA /* Watch */, FCC68871248A736700A0279D /* ViewControllers */, ); path = LoopFollow; @@ -1643,6 +1692,7 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, + BB01000000000002000000AA /* LoopFollowWatch */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, @@ -1656,6 +1706,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + BB01000000000001000000AA /* LoopFollowWatch.app */, ); name = Products; sourceTree = ""; @@ -1789,11 +1840,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 */, @@ -1806,6 +1859,28 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + BB01000000000003000000AA /* LoopFollowWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; + buildPhases = ( + BB01000000000004000000AA /* Sources */, + BB01000000000006000000AA /* Frameworks */, + BB01000000000005000000AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BB01000000000002000000AA /* LoopFollowWatch */, + ); + name = LoopFollowWatch; + packageProductDependencies = ( + ); + productName = LoopFollowWatch; + productReference = BB01000000000001000000AA /* LoopFollowWatch.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1846,6 +1921,7 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + BB01000000000003000000AA /* LoopFollowWatch */, ); }; /* End PBXProject section */ @@ -1995,6 +2071,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000005000000AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2395,6 +2478,14 @@ DDEF50422D479BAA00884336 /* LoopAPNSCarbsView.swift in Sources */, DDEF50432D479BBA00884336 /* LoopAPNSBolusView.swift in Sources */, DDEF50452D479BDA00884336 /* LoopAPNSRemoteView.swift in Sources */, + BB0100000000000F000000AA /* PhoneSessionManager.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BB01000000000004000000AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2407,6 +2498,12 @@ target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; }; + BB0100000000000C000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = BB01000000000003000000AA /* LoopFollowWatch */; + targetProxy = BB0100000000000D000000AA /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2772,6 +2869,53 @@ }; 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_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_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_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_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; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2811,6 +2955,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB01000000000007000000AA /* Debug */, + BB01000000000008000000AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ diff --git a/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme new file mode 100644 index 000000000..3ff5bdd29 --- /dev/null +++ b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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..4579331a1 --- /dev/null +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -0,0 +1,109 @@ +// LoopFollow +// PhoneSessionManager.swift + +import Foundation +import HealthKit +import WatchConnectivity + +class PhoneSessionManager: NSObject, WCSessionDelegate { + static let shared = PhoneSessionManager() + + private override init() { + super.init() + } + + func startSession() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + private func buildConfig() -> [String: Any] { + [ + "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.apnsKey.value, + "trcKeyId": Storage.shared.keyId.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, + ] + } + + 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(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if activationState == .activated { + sendConfig() + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: 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(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + if applicationContext["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via sendMessage (with reply) + func session(_ 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(_ session: WCSession, didReceiveMessage message: [String: Any]) { + if message["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via transferUserInfo + func session(_ 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 0000000000000000000000000000000000000000..8c1c15231e3b35288c91bbdbf211af3a91f57a90 GIT binary patch literal 56954 zcmeEu`9G9x`~S=s48qtWF_w_MBC<}AC2RIArm`f3$d+v;B9XO_Y!$Mv$<7qXPPS4+ z*$UZrgYS9K{d}J1`3t^3Jg@szbaS28c^=#OKHkT1Oqjl|IxRH^H4Fx$)znZmfWeU9 zTOle6%!r)dr!!_+6{`>Zz-Ri@}j{f)Vh%h)cjDr6e5AT1S z2WWX?y>(>HqND|Cz`C%;Rr#|6iQ`kDKfN@5_Zy48Lys_x9Y> z#jxsW*ogA%&g8kcnr%}?DMr$t;6E$nMiSv!y1Hie=Q*h`Re7m;&7Jo}y_%|8zv+ct z8{SjvJHJ+D*7Plsg8M%rUc8Cp&YN!DO)gvyQ@g;ZsTX%cHlx0m|7HDmU%$C?vs)cE zG)iCoj!j{>6kr}zF#YAP-w=<1%lUg{p9my8JRHF{bEL*JrP6DIUdwB=r(|K~j%nI< zRiH+7{`7$9GE&*+Ws&Z3oZxoj>3rS6pvoM%#*+JZVG3LG2XJF_OP5AM?cCkgZiCUX z_pwD>QTsz*Luvm8EPKJ|tUcMw9)V~7Viyf#;+x)1Qh zQBlcsrN=JwkF?w!w3Flt7CzQn^t<1lnW#5R88%eQ9i{#AaCi zn&Ot3qRi3huOy;C04te@;Rk}S@saEjYQzV21H)_nQ35XVi5JUiSq{Y~2C&?X7>fG` z@F4LqR=YDIu+l@H?kx9?T>ojI!2}NprDF(Blx^-)`ds5vIA`;a{-<`i60^P(?sQ)W zTE`B~QAni^bA6l?d9XEm5nQL`w=WE)g4<+nJ#wwe0q;k97{*gcLqC`I*r}K&gEI?B zW~XrTOYgDe)o#S3aQt71kAThmif8zF9zt&#)MkP^v@^DWE-DS%92x9!_C9Q{nP)HN0G=n)10IW=Cg3jo_oFC&KOt#9Nq zk(dNYgRKD#)uCy$1Pn^G(eZTA1s3v1HY98IOT6@NoCSv|HIkY7Q>=2>jo`YR{6LLgBsu~L>#=Jd06i}@9_V`TPl{Np~=36Ae8=H~DH^?>` z-qt*__<_lXYsOgt_K*zFSX>r>*!IF7cMg=IM$R!HO8S=1v=>y@%Cx_39Y1>P!w)#( zEVUUqVba~**5}IGJ(e`wx18!?Ew{`j}VwlDf2DT3+eR9;ZEi7!-KDYy(cEPt3@0f z`|cWT>vP|G6Kf+Q%j2=*8$^HUP0CVAJ0J~Zy; z#<)vj)~Z11Btc+rf#>+H0rg`}5EN;5m)p9;eSAz4-rOzGwz+g;E*OS*h4?*l0nc)? z9#0cL(SFmT25bLBgiP&@H>BoGBM_eSnAE=jwCsRgR1(u$-Je&r0 zwT*~zO-jxaK)-ne&+C5*|0MYV|66344!o4;3q}2DJPOzx!JKN&g;OJSvEilCZ6S0p-T3ROIHq!Q;CuX0$4o_gw(6qKatFvT%@Ng*fFjA`wdiXdZ ztm)ycb3G#6w`aAxesUkX_DMPC1Z=6*ft`hHcWI)fe(kSE-!9`6ypGErJaW2brh?Vmq= z7|3IYtKj|G-QjhRy?#VqTM9N~+EUEXC3zj6_sZwyPy>tBqO(tiwLKMiG{NW*j&-)L zX5;A*giNLun9Mjm90t~RB9Q5jwZ;WF88p zMq_y7wLV$!6G>e>f+&j)fUIX2T`aKb>-e!mZ)fA|k;+de{2@R3O4T24-(oz?EFehN z@go=ZXITXcTt@*Y`d#};z6&t;V{$ra`j&<7H}%AS$Q*UJp#0eI;tlZ9mstK}0k-Ts zM4^0z#*}Gsa(ejc-3Ks42=Giv(H}?Hz$@8`?UzR@Yb?2Z%dWiTo^e-(-GjE`$M0&d z6F=-~T>?2sugYK&;xMFof@h1hVm%@Ix`VjluhROA#68+CUNE9EZat|EX_AnGytMq@ zZLN)0`VQ{Jgx!PJU!Q)4%mZlv6co+NTmuEP9O^m8UwQ#w_j|j<**T_8?;DMLp$B0z zQG-~mIQFxQdg7jkFEP2aBh2&1xr1ey$%SAUs1MGNvWyU63?9$+L(E_qdKeDfdp8IK8nprb;dBX{@i7U0$t1TzJm?puGNN3)ss znA{9zhkEcOO$j)@TLO2B4Z(r1`WNH8R-eGzfc>;n9p7t z8O1U+2N#q#5BhQinN@Wy*4dP8CJeQ*&Kk$d)Uw5mu{w0zs~ zt)qLe2mKsq0+r(k7dH^09DZAGl>0~snbFb=B#VPMB(m5JH1O1Igk?X~evpJrd)Gbt z)wFu91?b6DbTKP+{Mp6NGsP`w7%s99-P|4S}Hga!Gnck(2llf;04DBF)<82e^Q*c9#nta$M%ig zO8QxMAXg3=vj+$Mx=zz(t9$YR?5}Z0gN=sb99cooI6dE#()!VupLD5S6xiY=5D zlb&95o2_PS?WU9Oqbh(PD+gfBdN5SM;M*=5VC|-Yd!3@w(+bjxkoM|XS|Wlcnx#Lu z#cj7e%G&YdL`H&lX$xTCxYO*Z>M#NpWwxUm)A1BJWb>(^GPF ztTJM~DCn0W2x(^oSm>`*o}Vf(LLYT)eT`)R=~f)4V|a?EjVJU%aLt`X{Jwb!?_|o- z{)g?!C1(L9@~-CC4PHEc2kdxQ<~0a5#hS#Vp?!5dnbM3jom8nHAU{lWHCESNW4uo~r#S0qJ)((}~ktf7#Uv^4$JEFz&k23nm@i z9yLze5QxhX1IqYnTMK@tmhmVDSIlVLkN|ysKkx3%QsuuW?Jk2^osg+gz5unT1TFw} z#q9zo3gem!BgY%+cvHZUQp{g!i_`-ajrcaUoDz>+fE*hwD}bhY-6}Sn&=OL9f{`|_ z6>*TBbOI&bh)2Ia59xyT&#A2!)!qA@ii+25#snc*g4I}rL>{(;N)$y)-yAqlAXX5Z zP0lXJ42aAKhrRN1zehxDxuw~Jed^}^Wi~EI^{7pC+y@e;v@$jmM5$sZ{>nkd$wSiw zzP*mL_I<6jx7(`bILpqlqM&^62xd$M?leTCoq3D@HkjaMi64zsuSFq2`hU;>p8HlFGnu918YdadhJIK zZ)nFUl$D<;mnmjDd>Ncc)=BqmwOG1AZul9*+0!AwcB-j?NTkm+J;370Q%47%S$!?4 z36v$ivIUnZB*bKEOmeC~MS)0+JG_uccJ>HbaD;*~Hk%k&gp-LJ9UK`%tS77>YK|)>bujc$jBH@f-hc2~za% zT^LCMR^tlHwf!q&)%g9>g%pXz(APl2>%jKyT{LKb%RRuV_-fbZDXonLWV<}7^9CXH zz(jOW{T8NVRyHSSgsV8lf~iKqjmHISWL>tG8sK3jjcmQ$z@2-~NvjX+uR1o|ktH40 z5r~e+{5<&CWS1+n`ob};zodLuPDB2b1WdU<`3ntj2OigKJN>;$FLm3EPXx!>@GA$G z3D{OG_O_r?R#Ee_843{D>oyZ|Qv3Fc+9FW5{3k?HxjNe~sCw1ZwBBECa_MTt@sj~_&Irrg<5 zU(4B-7hQPmk&2=_R2dVZeWrB4jWA&Fq#a2R)ZPS+`VqX%l^=ka3`Eh~OzeV^r_naW zvtKR-{MLuz9IHa8&A+>~N;^>McD^v=6L=HK8%obbe6}xQuM^&RUjPw^!snjW>*mXh zPy%}3a=K$vE8&xc;cX|93+L%^ASjOm-#HqHg5il~gx$wv%k)v4@qD*SN+cGU{ywy6 zfV*s*<+nb?aZ=*p_UVB8ATI5I{e5xJ)Ix1qk8QY=x6`LNKY4N0etX6LH2Yuk5Tvqo z2%a$fY|{Gm!YF`WOt}VX=5bOTKo%ZfsPKJkwweQe_w^XI2N+KV$cElD!XgMM$S&wtd|`ZNxB^f9~A*V;tka zfKEahCNyfHkj9v^Pofu;KV{o*Eud!5?0{?SlQsVM?WjVQ|9+1=l&P`8!rVhlX#T0-+zy%9{=;*o8E zbp^#mH8#fzE>NwC5Tbpn_LJqm+%h?i%Xk2^>_Ce zTpsa#oA%cGKkgZlg14M%q(Y!P1u%slpIM__wa1op{w(hba`3;i3XEj$ll=QwtssED z@e|Hp%~z0syQ&m&ody{@iZzZH`VKo8Y} zhgLgjV|XOh#vc4hSx!-pYLR5xjk@!L@~^UbRB+ZlS)`%kw(=pcHs{Qhqigo&}+6@Ph}j4dE?8Hr4yYlZZj>{r#-? zP2X$oA5Ru79i*PnvC>~*b=5TLU3WOm4W1WV_ihTte*(LU&{YolY>vakM+fSk*=?w* zP@k3z`zM5>WiMi4Qw7qhax4Z@p9Qu#DS&kBJ4C&8rKU0@8l{(cY3zLr3bl0^CuZy{ zBK|Rr^fG+801-$%+R21vmQ$dF2loUR;H%zF+%Cp7zyQDZgtAz zSG2vtMg%5k4Q=mIY5>DgL6Eu4Pbls${fN0!wD_<0j0F-{R@^7_Wcn}JOHJ&*lSCl> zguzwuI*e})N2O4gsZzi5qtP!)rr4KKlr(SRe>YcQRsFdnOS;F$b; z?Av`gg=WB-9KJ?dNZ$kk`(0##Pd%|mdOVDvf&T#(bR&TgL0yf#B@~l9x3D&%c%EB$97;P5 z_Ls%n(t4ydVIe>6qOrN*{Ks&Roksx``Eojv6P_TqDMBX$_g-3iW>4<@D++urI$Z zz3r_U5~9X$?@uepA3(Zeb*1%*pRWHxR8G;m;MEfi^ig{qF9lA%jz7+T#Zm?L^-ZiC zyJ@@jQb7U4u=7CJbU%Bg5Q50B_kJ$R&~rX%yj^m5dmLfapZ;Nxcs%~)a4~ak5h?#} zYrBcfP4j%FsJPLA?y6@q!8R##uPq%GmA+CKohMe$X)mgG!P!BYTlHUo* ztaH%FAB_pRbBRLflEe$%v4{9Ir+IL(#RK|LwpZyfNGG=?ajLhmKvwPuu`74Bn#m|L zO8Dlj1m6Ju*@B8WC#)c!y|AFjZOu5DO*3`QQji4bJ&$GX&(-_f%JEY5! zB~<_*$HW{1pi@41zPp#x@7&y<*+zLqm)16$r~=Ic_3I-^g`Ahn)rEvTJ1^Vy7aEK~d_w-Bd}@BT{c5H}j^rjd5_@dh;#rg||(tuVsF}t6PLZYTyxAd~B;Q47 zqvNp`kEAfMbzg`atavfqK&PYPma{IP?uU|Zoou11>6J*qNLpiFP$PB*hT-4n)^m!i zhh8|79g8qTaV<1;qrQj{pTa!Dds8(*z|n3yd%gS$Zs&#d)`IeMT;R8>j%Y$0GCy-O zUvJa2t2+in_YW5LK^}6PJS0TDDT&5@rJif(O_6DVl4SPv#HL^@2ARloKn`+&dvg(% zA|AwpOCeb%?5;TKNiMevtukhdb%NSPk&z%t>t@-BWM{Gdk0JO5cvEk_=)3m1S(BCk1uZ5 zjFetxLC#be6Q|iv_!W^W`CWVfQSP6wf}%k^XxkeLmtx2eMcUm9X{N1;-~e*+S)QLYXci*vRl_Pc^Z*Qo^}tce8n%q8hg839RT-& zqqtG~L7Q+TbZ5d+S-wSa-##nta`y$y%lT287XphlM7iHKiGt5A>ux)|Yqca3*B_Nj z6r})_Nsw9LqvC(?;EITXg@sS@Ojw7$2^H|5B|+qdYY8>pQNw}qu-`Z#NPBGJh(rN} zJ~64(rOG0A;inz>4CB64>!U16*C$7);0_0YB3!ssGE91@TQ!c}*D zuHu>-vFKt;5Vb~LzG0IksC1GoucdK5NhSOsK7I`6!Bqf?LCU8$aLJt9AepVC#^k`==r=1ofSm6oe6xXPH>`YBdj~4Ih zr?uDh9Y^e;HB7yd{Ffrn-F!J@6^PV+f~tg=?~AvpmlFUTrr@(Y`nA(QXRnR(Q}v1B zT5-!ltIJQ4KPczVuQ-4p>P5~We{H${+wtX&c)H(nuk`;ED%JfK#}!DeJl*)5bHcvN zgZ}9z+SM3{A#eEZ6dU1fYNg-q9$D`fh7GNjrZBt+f_&(=+tAPhx2o-2(MQ$2f+^tU z=trnD@gUMO$M8V80ZC$73JH#(hz(>b4 zupk8#O^tThTTQQZ&){Z4Mz9J=JZzE9dlZ|nHEmn!m-dO0EQmgsSh%g^v)cJ~f*L`^ zibkpGvNx@g3)Ao|OB*AFI;iyaJ8Q{67lA$urGJ{et%E#dz(zH2N58JNyuxAX@uDu$ zPrUfeSxaXNL(x3esY@*u>JYw0oap&_JYlq9Ks?IjWZxD|!F^VXgY0s4R_*QvbUp-{C&Vl+4Uz!;D< znYNU4vWNRvyr7I%i;Im*+*>Y_>QgI1l3xAo{mEx->dPXxZgnUkBe>5UmJ|b4@4h0b z?J_il;588pZuH!6AZJWPST7xAJG(4sUiLC}m6PFVZMfE;3s#tD=wP zLjVk!FY@E;pV_CO*3MVCIO$N=k@@S*FU~!a^j6 zj5EcpENr9&&DQq72e+W@pip1#P6dji(uf=sRK*31xv2c?Gqeng#QIVNky2bNfp&&f z_AF8xkP0}1x9qdo#Mzpf$px-L)3kdLN*~COh?CI?PYcCYB;uV0UhkP-7Eq+Njb&#> z(4lV0-(W#l*zUYgqO^FeiCksa1e}uFQYpe8!OMu`WCdYUQnGVauvkSbbXBZ;*v4vP zrzHfdPoB>d=ya{Y$%Iuit9mXCN}v+Mh`MVoQ{6VV;tMEGB0HL*HhxJ&7w(0T%Dzpv zJld+-rql{XeSx@Z3e}{=D6Ic%I%&XvCo^F(iWDcsn-Y)rh-xT*k|tfnEx6bB^11_9 z^8zlfT6#>dTc&mX6`A4O2S7O)V)$a?t+-szpUC^Mc_v6z5z3qTK3%i1p}D(C*J?xh z7~nYnXy+a$G78lE2lMv^Cju&xZG@|4`VN|^h*X5f3!d#{5>jP1@l2I(e&=8VJgfay zf>uD6(~KkM-AH^NxOS6Sv;4)7AZl&Rrg28sJ@#{Zaq$A`{aN^I?+1~tld<1ECum%D zZhGr^(9n{ojd>x^<8#$Ffuudc(KD=h+5fa)>P8w?N7JbO&VqtK`=rRx-=raF%X&UyBgP@57DW~Azk!F?$eio;w81(q?_#lMJP4N`qGy?RwlVrY> zjCAX&eW*&oVn64%ep_8~n)I1fpHC|CjJ*Wtj;-W(+cCoahIf!cea^`9uHP(hN+heX zU2CYgM8XH@A-XrZaUe|rrFn=cWxM$S|Iz3aE>VuSy7B#++}JVuUWoi z?rm;P>Cs=Z8cAyj!Kaano0~+}b~i{0&P`uM%Og-n1#mjdoBdOC<#%yqW)&!~Gj+Jr zgOr9^r^4jRoy+)u4(?NWqTo8e#+JHPf;9xx$jkVh+4*UDI_}q>HhZR@I&I z%5Rk(x5I`DOvbOO5W9h5Ny&=WxLLgIwL(dwqsNc$KBGi!o`*P;ikzWcnPeN@tA~{x zNzJi8yNb(oqcR$X4#OE&&zPjqK%8>?_?i!$4t*K||63gkYi=;?+}wXmN!Q}a3k$l6 zYDS5sJr_%>tiB{1{<1mZ838H;kCTIT5)vts)yMXF9)IEqR|R@yCn2=42$|n5*@|tz z#jFE9{52bhtoQiVd(6^vpjG+oNuOme2A|c%2=eiBSG~L-Zf-3*&vMW!(!nCzBZUDp z?87-14k9e!`@AIGy)dlmumpb3fjq;@gCueQP1 ze>&0}PvAQFNnZ4y+h=Qh-#iM(fwm2d;2D1Ino!H;mpu|o*#Yapva~{A4ZxS zaksletF?76^$0>k6QpmKwC6Iyp$gj`Nqr~7CHmyPM=RBxWZ~j`aZ6ClyLqy8+8 z1FG<%6H@`E;ZY7KH0Sbvb^D%+tqanMQOLk_Yj-0`@?^T!@ZB?7AmW!HC;rS+9cf~` z+1s$|M?J=MPEp4YDk*x|SP94!ipRfs^Tx6M`N%<0EsGIkFj0Qf73!IFUN>?_Kp{X6 z*_9M0X5$~25`2NQv!IFiC1nSeJ!x3->N8@0`g15~6}w^`dVU`TSSr(ap0uhGipt1hqJgy1`Yf83(TJm-Mq z8u}u^=5e1eqKvs%X$eX?g9jet4_jg)X`tJ`e&06eNb@s>mOHoP661+!-wpVj zx;xSaeZR27Wk5i#%+awq!+4MbZ}kPeG}*hZf)zQa1HGyK95{L3s^BN@Q9ySQk%Y#A zpler-dl&ok1phd#mIn28F3WHy2@ZT-y*NRt)4axM_ALCx$lhJIiE9JUvG3ldv)4F?iV-Eu?95+ zvg-4uD2_fXyMzu6=0boQC87Jf)hl&LRRRc%;LdnI_QDzg#P&*|H0DI$YTKisPt21c zlY0HH_Z&J~PaV7e2`YSWP@8Fy?=RRKkKffH<4{xA5TN!W0q*9WT}+sYLk9=Q^?PK` z352c#K);nnKk!_e_(ht@Oi9L4$=C4lghQxwc!Vn&Dy!+Dng+z+qox%|g%4tIL5dQvBSS6tO(K6>IR$FFeP^=2eq z5WyxPqOqs=Lv#A>Bcd!kD~przebjeq(&mtW?p~b^ypEeKTckbO#s5aV>vA^_)Lpb` zh&yj*vK;rhZ2GiIxbm5!$=PY6gD1Oh+hsl)v+W<{`#}v0oR-l4rp18Lx#*koYGt${r{HA+QMg*S5M| zsq-r})q;$;z$N7Rl`A)NsE^XFz=K?3S&7(l+lWM?23kj1zzr|9FaXXWz7qezX=GtP zQ+~>InY+05G$-%@z}S?F?|fc(CNdaXcnjst<67*a521gyD}91s+srVvlip)bi2$vd zZ0v6M4k+&OVg^23-a6*dun(~6je{x_(hPG>0>ACwkp0MR2$5`G5W2pZMF;vY69!K) zS``DIM9MdOjdBL{5rqy#pRdy=Ti}Hj(a&o+yDhQ~Tlz7ZhzB#&_=alVLbH`RfI%J* zy#fP`06}YgS0@dHB8Sl-x{>!NLnhO>&WYZ1<~oa1V5s!bDe?QUZqeGMv5^rdchGRX zs0R3jcYCXkrQNzu*nLN0i_~aV>G&t)8x(W*D5j!tujr9c6nbzXpLaFOR%&j*ThFqy z5+CpHfQr!2s9;gS6JLqE=xFH`?t=~{d<>!@U+ihOOLmNg+&=43ADFl7FR1eMN#>hk z1inxQ^ornaPAPJ2oUxRfn7lH=rG9=xfZF!rfNh{xR}4VFr|)b=fW`#SB9Kh1{;;(? zP&30X5uW$1`P1U7M#P5lH~vryisSPSrWlTj0FaeWsa%DDkm7>!(AWjNy97{i?j;qI z*6LO!l6|z-WlTa2x}(a-SwGvh%k($UgA!;N&(XR(#L^FjEp-q1x#;b#5WFSO-EeGH zS_Zy+B5f_)S+Yw)j*iNUoYlxaw{>r-J-PRYmweV?@zn-pl-r7HcrkcGxlEv)ZHduV z67Xj5z-2!}Ns2}xKOT^ausKreMi0gY`f^a*KZ8;GNkbS2cbjy&**yK8b1-22;(HXR zN}v}tFcKo7flGUG<`uHtuTZ}RRSBcFZpGcE^wr^%yS@%hdQ$$%@~qmqp{%v{EXj}l zqyOZ&2g_+LIZtl{+jwzh5elK32sFisAR*vWb?=1xhfM)TCHnqq@%6MoU)6k;(l2ON z3w*=F6xOWW45*Mn`p|q+kQ41pJq$u9>0P-oV`w|;){hrO>Z_$jhYH?i^$ze&s4aPJTzbnsv+53zc{5@0)$T#V#4DFKL!**n zypjZbd3>2i>%Os7^*I8a9@Geh(l#Ylvy6eN1b_aXKLCl2guq?@pjB59d|!uJ1u)NF ztMba51hWhEE58`7b_Grs{65bw!V#-%8Yfe5E~xdn>_Gz_a*oNYM%Jd@Z<@2!R*8K_ z-_h{`A@V!V7Q6zXeWNjM9T1ttA7e}e7&fCEI5Kn2D7EG(JjVMYe9V6@C)~{1&nzEW zm%gM0uo&1BC!3i`0-w$0i&M!*_WY*OT}?1PB1A4{a#xd~;lV9hd*}J%+*?!ripxz? zbRTzKkRczrH7=L?PPLI9X-gi&CaA(2F_UjwG9)Yq2;mIk2G_U=OweD)`8fjLI^^X# z=O5I#EYTo8gQJ&vOgu$czW)&5QTNO&elkX&E}%0`wnWR+XQFa!lflOq!s$9#Zd5pv zd*^<#f~n0Xf&}j4jZWuH&}gG+*&RKEiuitqkQ94kd7=SirQSAop>qIWNd?dQMUpo5 z6K{`7hU!jV#1~0#b$x;?;kUe`>&{cdvbf=N5rN;{S^M0hN!XLeYQ+IkBVh-Sy=;@q zA3tk~DnRH3V!B^SCW#`9SS|;9h7Gqk=Uc+>nX?dEhUKMiP{VXvIlejrwrD}AIdeZG zYqb2TL~h%YhRQtM<}uhvKIkPemRBAW_kpB-Ln|1gBUD;f!-@yLno;qJP2-) z2ut#Sb%Qb~Ps!Paf^4a?YQ!Y`@9t4k!69~Sq7+34_km~>7>Wy3U!lgR>Q1F7fxgGN z{Dm|2wMY8R0V3<#<`~$nZw>%(|M}(X_Qmtm$YEY|f$u zo)`a_9p$+n`t92CVxHJLF8){U7lBKQbi7-bhBx1M43{N&ZTQ+(8=4ovFx3gz9JRc_^ z@K<6(-mVP4)g20WXs$`C)9|YCZT$8Qqu_od$3f#fRIbaMnHaSweJh)A!YKt>Vc$FJ zvfRKjfT$g+fbP=n_trCj`VQs|8}MHMxr__AXuq5q7{6U+8NUu`(d9EZQop@d6`Sy(!Kn-Nd}sy`-?o&dKa!r2%p;Q!L{Yeo z{|;m#qykUF(C*6Ppq^2_r;!_HwWr~xk!XLA1Jt4uU+wvqJ^D7pPWt`lL7@H)sRFcX zyomp4uL8P^NUTYbtUChajC_1hnu=<0mc`65XX}@p2sGZk^CYdaF4nWUmlbkCJQrf6 z|Ih8g;j^Dylepjv4;IMj%8#^N_rpx@C{!!LsA10Gl<5tx&w7FS837g%%=3#^{|S?B zz-faPRn6**;6u2>iIy!Bu-sb_E06868Ke7^G&G@SqhB#`nkW5PYubY3i|iF-h^iUu{i5vly@IgVFNc8%RZVOI%{U)rjA%Z?k&1ou5k<=J(cH=( zN2lg*P`+XVbZW~hr&pB=LjooCTy`o%Izog-QqrHmNVAPSPy&QJ2=|EiK#OMLm8nVM zX{+Yn#l$j8`N@<<(e^)*?zp7aFm$4koHBZrEqsk8gcdH3eE<8fF8;vS+*3;ZaB2>< zd1(yfk&|6Cm;71Iyqgh;PDbl@_I(H;{svZ@to!hD9H1xI+UQfS)m^5e^hNgedqkzx zRH=bUuP=R=f`dqn0W^@jglHV-qu7#kyD+GO3+zi*D9KT&D1NA}0-^zjz{QWzsiWUN zGzH2DG`~LOPs+Vy4hl_ruLN%$hS@nV)Iturh(Fg;IJTM*D6MbArtV$6{;`{A2-2SD zq_|JMwArl}=S`v0<3fqIXD?0UKK1*0HT1;ogFS58fEl*%ojD!p(ubBmNom?q>bTCn zHIQ3F7^oUbC=^vVnl{=^Ei+OkFG{rp@2gba^&t#<+^O29d=E_vfdE^48N)%9<8$X` z=1a+I*Yl+8UJ@5%%G*JC2}(;&g0iUwLtFodMfFc;>LQCS?anu$;%fnse#>8lk6C7% zJUq;OzUZ{$CL-hgn${-wTq!73HKP;II`kdMYr&Z?j&qe$lN;k<}M*5+jvzW!cOx8%P#4W_f- zZUYy*LDp%Pk5CPFxzKrqqU+&p7NJi1cD98?J`n3hzEEpjKe$W@%(AcKES21^dtg2` zrw=o2e(KY?R`3(3*&Dvc%MDshHR18Q5|gTtB2_3+0}Dwq?H-A=K5y%v|RcmktSR7@uWLwbI>)n zAN$w_J8)(Yf5rwqGToNUw>)LEd?~0%6I=G|5qX;MFKAi7I1n|DDkun`xi7Wo+1OGs zM1~Gg`As~cHZR9}Ja%~9Qgg6~Hfym!w_=%GyDg$wiH5MA%CoiRR8V$MnU>;tk1Rwly@9_I~G`0MPDm{n4JTz^X{~|9^NIMK^j`W zm&ZBA9Gr9GXMP?DwRmlQXtU_bmG=hbInL$RRdCe^3Fn2_pw9e7=_?hOc|ADhp?S)? zL^{w#amG^Keo^=PeS8a;D~VfSojMO?r;rEIQ3v;P)C9U2J_b`boO2Jdf`oAXO%7r- zEugy?7D>n;?siMJqPX|mr8=-=|jmX=oSg#ES*e4pL*z!Qs8sa!tO zuH394z;`7{u;P%0of{2cPJ)sO3?=AWCh-LNsg!5r=<)vzsh}5&aBgOFRJpqTYf9Iz z6Z#;X`DhHfB7YsBP=U(7=Y89|ttZ&d_qfe8d*^HJ=a+|_8Swf$!+>@*#f|**tGeS+ zQj^mAeBoebD67tIae7C!>l%zSsYFwYsU4_3a$}|bJLbrPQC1B==kg}d@B2MhIEHu)!;!sAdydmYD?4=8d?^B@romi2u77P@p5S^f z$!~s&qt$o?QOpyaO9X!8bKT1;x2DX72m&!u^hFn!IC{d-+O zCa!-Ng7J6?MCKO5*6QO9%JlQXek|{UB+561t?!rX0Z8N@U+?t$wbkBuO|s8%S5WKq zb5?n5mL?0@9}c7sF2_99i8e7O^BDE0Tbq23>Sl!TG8?Wec;!+%mnY1yxwTatC(!iJ zwyxs0z3@EP5uNSPwhwwqwKTb$=$)eJE*!*9sPp^^+tb!qiz%|BLGS4esa;u!PZS9L zF$gTqP=1Y=xD|Ml+gi~W#DNoTUA4o8He>964RFy4!+a^O`+JigELHHmI(*LYXmo`c z15us5Owb7G-QUiA3UXaL2mgkmYXW92Tp)r6$2!0D?>yPt(mCQ+_{jy`C{-sed$R8oQT zAc$1*^LfzEyyCJ!MPIKcw&-qxOym&46&b)#W*78il0&Vpwnp`_FKzoG64I_Zgot+dzpiZHy)L!k7`kroJsS_;oIFg&nHW&IZ4_ zw>dCKl{T#j1`W*8j+$xhnWtg?WeUpV&ay>j@!g&44vVUPa*2aM{-qU!Z^2hCZe*X? ztKL9;|Koh@>-FWfLh*{4Oyx9ch$aNHmGQ-TeFsCkKoB6S59 z^FD7DFWyx<@0~JAalYjm_4VStATD;ztLmPotf5}@;kSkT-jrZ`6uO2{_dzqis*>Bq zi|CN|M>$!&*HWBIl%#BF-U1>IH!X9n8Z-qc)RZ^kV@@{>QG-Ex2uW!@Jh5-D)w*+d z*Yv23f%(?S;RjV;n^Z9OsLi-pl}cmsV5=RG{+BPqin|TI#Rrs?=!7tR4N=E&Pz~n^ z(Bz7D^$77^ILI%+M&yx7J~~f98R*+5=G#cKv7v&L-XVGBF^1Dpp!GwU0wRFII{(1A zPc)XPjLlV@bL-6Il}~y}``4m-w9_SJM7bN12K=S8!aaX9g{n_{lKJ{3JRCmYL(Y}$ z>y2;A)m8366s`#OTeUOEM`J#Ie|g_Qa$ZGZx$V{Iye20j^sjrebOFT+FmU=uM^=$3JqYBd2zM zzx<-zoCUie>cc&<@cmX?@U~=(m^VNm_Y?X)G3-uxDD8587<(6tV33|wPT14aXoTvP zz!Z=sL<<%$JTga&j3RVer)N58oSmOf^;db5o&wzJkHIxPYz^H;YibDGm*dq_BK5Gx zT1Bzm!Mv87vO2D=@w=4KS2^V4Pa_>;1Ui#rJmz51u}A zYn-_TdMY6c!V9A?Xz*f(V=vtkHv%@)Y9b^Z z)M00wMx&jr3`O1BHYrDyE?XQ9zYqfvJZoXZk3~7m&d5cZ`Qb;KAC6z?9r&2>)7huy z6FoUh=E;@0MvJnQ-(sTQe(+W;R0{*_b{tGfbbE#I;SP<66K!Vp?1X87tu%)62HB^H zp2dftQmlx+eiaQ3oFL~kyaOx~RzEVrkFhkC7!ob^WzMt(ZhIyjW<6kjjJ0y-CzvPG z-B>|C963+;DC6-Ebkxy=z#QQ3TE>3~W%oQbSi~V92F-5mWi6HUkH0V$EolrYT33|3 z9jkI09fn^s{Q7k~*3{D@#a_aC{qWN%gdfOy4Q{l)h=0etZp+35(a6{Qx4Zw*8>n(s z+WNc@&1vP>mr~}7X7-*Fb0$Nzy#Oqi&@-d)vA%uSi9W&$z?40EGNsOW?aMI7X79hWxu?uLR|DbH*NLQ;DyyU}F!e$^0$K+}#`_$%^5QrMW`Fg9gfn zbs1oGK;eVb=RX$;C(YTV%2^?H2~A@dP6fwH z03RG-J8*wE%=+iSj5cI@xCp$*@_s#y)<=bp^e8jL;HB#l^SIO425wqVd`&&i0(}Za z?=U+(QRC?S5%5_E4X(3?Z4L*fi!mmEIRH)2>oqVk)Quqso;|~!KH8@PCoZrXEh%^9 zC&OD_o>8f^9@((DlKew^{0%gr3ppDB%rrlPZNYe=b5hpQ*UuhlsaF#qOP0NIk|M%+vgttqPXFIoin{dTS-GYget+IWL|HboKo|WxAErL#lvQw%frfuzw6hiEOOL9V2n&*RHD$hr^lB=KENRo%w3-Hwb36vu^HE z_FsZY%>Oz|IH+H~#Hyd<4d z#~Ly98$$&^)=8$&q)PIm9Y>|zjXUefH2#`8@nD|6yNd3{0SyiW9>E~gLEBDy)8Hz& zs8~V&X#?l&s1y=&ls_+Engzo+xU)P)J4I^DLfva=l*5O8+1vN-a2~`qz$*A7WYTPS zeat&VY}0N?THJFMso>o?6$jp(D9uvlv8;U5VO=>btGlH5vshQ$ncOl?r?im2Mov@! zpFDEag!Qzhg$SLW{k{&(GM-noA6UxHaB$z{@9Hy2X1_r6rg=pa%iD= zycWW=NHn(e{zI+0Pmj65)Mcpcw|7d)!IvTg!JQyW)hI!Ktbg~cJpIPb39FxXHK73n z19N7N9GU8Wf_fap2e^IQHi!%9Ff&AZ$A5zv==VAS=HcEXU*?TXda)A%uRJfA?w=T~ zukXk{b;j&a#{ejg#oCb@Ty8Oj&F9XJd~;g3s}*?5YylJI3_dpme~NQ&^Z$|c-SJeu z|Nr+n9N8mVLb7+6Wt~bQL}btGO(>fqDvFS@SJ@-UmUT*GlbOA#l%0`re%Hb2^Zq`5 z|M=^;@9Vy<*L=QSuje)0bXN6nF;cafex7#GRX^2tWD~G{00~1Sz|Y-pm_BQ-j|7`d z2znL!$1KOW1LkTdfHB))Q|5lMQ)J+lktZ6kt|cR3#P&w0wZ_RmY?_!9TYNZ5VJ}8(gm`t?JV=OdCG3U;|;HoT~?+a|C zzqTvRD~f&`;(a%VlXN8=If$^br{LF zyb=kh_qa9RfG^Q4Ds%F~y)xgKR${a6T!Z{%wLpNW{9WW>ybEgXgBQ;@f{wJRU%#&M z&P-sanfgA zJJF|=v`j0DW?x7@CpdZMs>#$-_op1f{NsDbP$UVA3$LG@OpD+O%0EU-UkH?}{sD%EkKUT*V}_(8ou51a#j?{8 zt7!$J%bU0isE@iTx*EzpMb}feEk>$j=qZz)v9Vn&mC+*=cn9D`&!4^4*F7*TfAQzO zF-HzRPS{?f|8nk{8&|hH25pcDq#9o`K-T(P|Mmqe)t#P&pdne~nt#)x-@*W4YTxnZ?C#_TDbg_{gT zn$f}MAKlikMrA#-xxx@ynUCnSq-M3Zk5eIrMzRz#3tW`8n12t6T)gULIK?D);F*+s zcp`0pcBMeq?F@=6yx!$uLE;~ka|r0bjXi1W&r`nm_l!bOdDaIFmX}m8i~$THe#d(Q znwgAY8qLg#^&KfGS6p-!#lxt&9Dj+WI2nR*Z~pJ-y~&Zr@C5SWzeTMiu?s3XFZDhA zm!kuZj<7dqRAT(Js?zlo04@W5`l=E4DQnvWhP>otjJ%BaOS+7xK(Qj=Yb!C8X zV~dzW{+nL&sWV&of8Ui?_S-{_KG*@p^AxQ-6)bb|q!GQ-`aHU>dC4|g9#|eOtjFIX z(TjnVBnYl<9kM9rwssBI(u3H>pIq_$w@<$?8FbllJeo1{0!c|u0Qi?Y{vR*{oi1^y z`kpm(qd7{G_WM~SP+&N`<7p#*%z+?1o?Xd2MU489h;YI@i(TLz>LeSfV!+aifHxAx z3%Y*QbjB0eBcvR5OYio83pT66kk7P6qoeBXO)%uOhALkMpZ_kFjBXy!uZ zlf5t95?8O01H(A34WA#MmJ1d8b`EF;M}sPqtIRG#I%{s-@<$`>s5lPZh_-_{ zt!&LUo;Rn3eXGTCR=f=?+5X%T>NtBCNOp1Bm~fEx-rxb|U%-ZKN6vblad#Py&^|zm zk-id^`DX5y9O&Wwqn7u_KLSiewakm~-bHxiWVx4QNeQ3s`FcSBCt^qrQKlE5Q~%1{ zVu%{%ljeuz{6bc+S}LlN^-kFl4t|#U-N}w@i<86|hW`)@s^G^nPSbFF_fB>63!{oY`$TFxq2o)$ z;_ka{vU%RW47ay2D}^=}!+AsDnBLwI$M@FFR{!~bN+QEb*c^d>e7Fm7(A!gBSpDPd zjtZDZvSf4yiW95FS|49IUJekrXDT@iS8C;JGFkCoPkUQCy@z zJsAdPajt&g4>>W!VuK!CemjgIsotL)<>&$ZwnrjX#Yd^_4i(>@o}#p^b#!2y{xic(pyI!d?eUWM6F8j{=^^7gW>ovMZuj@K1JnsvM~_e%gD>NRDCcGKI;XhRPs{>*E!R z0-v4k4VVH38LhvP+K;QWcujD7r6w{ln+M%uU-hSe%0e|zaHF%R@n}WU{kOUT*j#x z%l9mH<<(wXVL17y^a#`+$a2n6?^$uoe4@8dD=5-et?`?&1pavbX4m(n3N>&y6PBLB;eWfb2881_6xs^swMof!$2oD+$w ztl}wB*QEuP+&;n(S33c80AYwiZ)ic+^430kH-Q0)9~x5u=4!hzh8xpJL@%Am?*v+R z8xJre9KpzwXb5QtD~j)z5Pweapg8Oj#Eg*3yGIOh-<3Lu+QVSveYas}hwh*C3B(J& zvciU*81Rh~&>zUJNd6YvZev&x<9BO@gTK|V)i*4y?>9$xtGmiCsau6o@2!;I3+Rvk zG`{2Vl1K9uSp*b1VIvakxg5nr#nIL=6{~gVDJVp4y)0{!HpvneFT0pZT~=KGo3;F@ zw$mTbgoP`K2*C^S(p9)VMKq3b`FkaUnt}$XsXtynl&iwXD>!z3xa@~OK%JUbPoNXF z^w+3La_C7Ce2jqd77(_O+2kKt_l30#ug(o-7yMk0FSHft<6?p1GH`qk+FZ>B$+wOI z1L86>#2P4J6}-=~>2}1>DKq?o;*3Z*vZwiSMIip<;Zi)E`7x>p2r3cev!2{)qSTIh zH8sOTY0&3uc9BPTn{M&w6- zdo zFAa1O04#$u#1hJZ2GGGaO?5DI39$6Q z=b@A*i`t}~^5i*iJFTd2whpXlBmtbrr~Cuw^7y7ZPiY0Or4QzlxWLfmf@AP>ZB0UK zBRqB&!YOU@gkQB?l*?y`7GMqj_1$LLgY}Y)(^E{owbj~o>#h2OZTkMGK^=+Md|Jm{ z^^PbHDlAbj)`SCneJ-pi)~>WVP5M0B((xj#-wpC@bWRNAi%D_xomk5F@+EWGQH)(2 zH5?}q?)X(?=wzc^M&2c8C(qj!BnIT-YX^*5rJE`Jp#-}(+C#;tIB+lEL$7Eia&b)+ zU-qdcV^cB*wXvVeE=i2gIW~uHO^KXVOp4b}XnKJAWrQz%Q)>-Z*k$Gvh5QI)b1wn$ z48mW_s3c^2;tO|31okBE{t;0vcKrcFg|5gV6WM@0s6Ox|j2PqA(H9^XTmMt-@c8p1?f+e&< zX=ZlKy){A<>bS}96$g0^!aJ~tOc3HrT;9hXP)}_Z@3Utmfe3O7 zT>4b7umm$ZF7lqm&u_EP)cQ!S(#+``4cp$(w~u~UYI+NM(x$0aBuG<+2>Z;}`3g5x zcDmbgGpPh{${~z)f<|&$wxL~td1<@sD{^`Gvx#=E7$Mk<{fLLV@&B2KjC&@p> zt^#AD9Q2PMe_ld@MAaHdbdo}XeOEP3cGRp2YOr8TMZ-VYT=@grCt=y_odgx@ zeU9fuv_%Zfh?WErQqb@>HuR|fy&(DGtHjtfg%43G)y-xwYltCP<|+<0?;byRGO>%IN`8?N1j@^NPs`KR|=z?5HD?x+q45j)qp03 zdH3wJ2w+54*2hD8emx)~zYP&S)fs(oCO;uD+sW;paD~&`T4rahUFz_J1oBhgefzYy zaew}kA|mCZAcT0&=zsefEg<=!R$9f$Z8_B7V3(4Ld}u*{!W04^S+cXNg%pJ{6L*ef z1lZb>9-M-E!2*UZ%02R?Ky}~ojuG^NAy9}gV(FkCe2=OV{Isu$lR$~d^5(d`szdxt zaLV(12oHU(KX90#39U|w^|AM9!ygV*4f_m&2^Z-QT%|0VL0NH1KM#(w7ci{LY5yK zLB;Tgn{=>#iA(sn=Qm@YzeBSii<*9)SNm@I;VwvjJ65z-3oMANA)0WT?~lZ3FI+p1 z>&F8VM1ims&$kR|CY$OnBdIRpp>K2?sI8?%VX_nGsu65#dJ#!kQ2ISLLR_IboSuh| z??oc9wGmTViwpsp3Gz0Z_u@mKm|w5UIwc!I>zty8`}RPd(ykHUvK1ea)xFq>-8JbC z@9`_O_ndCR&@NvOeqX=?FHlQ*E5NU#)%#Q|;yKR9VvF+jZPoL=&s7&Bp0>`FW%l(| zr*!CF0%ebuFy4lk?3^5{WN~Qyn&`e`Hs;I`tLw4ZIgfA8#|tq;^RNb6I*GGh(AENH z{ima>`3VNn5O;Y2M=cPs%be&&@l_Nfq?38!-Id}R0}~^XR1hs)d9$x>_BZg*I@|Q) zL$Pd|s2yA?j1Q~#xNuJ8uvtp3b=f}PDL85^_dvrtd2r-Ft+ERlge4|FPL_hq5PJ9` zdZuR$7wMqD(oZ>bfwH1+y`O2jR$-|C-};FfocuOa5iH?r)s10()1toxpb6i!Th$?q zH#-j>oqs=uHcLP$<3Bo9NSP}z>nX&4I^hdj6@rbTkoi^?6osc*pTm(I4~u%2@MBTe zl?3&Jmm>hD{>bzXGPwn-lK z9k<~y68yFSr?~w=t1Ijq3~-Xd#h_P>d02newoQ|NXZLABV1P_>;LQb_XsVNl1Vscl zp+YrcN7N4&5-~nUQo@dcrQ1(f)`l3AE%5$NP88B#Cd9>BUaY*!kpm7^GHYzFItfR=ZFaiUtZkkMG5TEv!}h?FWoeF15LfN$u8d%Ue4hXh4u%=CYeMb_}zgIU%kS`p|V?)A8|R3IQ*Wqi9t-e5y2-xsG4U! zfLs)Et8Cj<1+P-I>I~bzW~s)m+ z=y$O7;kJLWXg85{rPzMd`a&9}ypt>lw`_lSM}3)a&PGJ&@W958ISlx`bxFfjVmXYk zlH~wVQwLPcmqv;U$2aNBwz9A`=JKHKhAt4rhSY???40UTY>NTn;klg9hh4>S5r)aw+?!8BkQi@I&2IOeP|%@N$r!;j3E& zN9@hOG$xW1AC@c-tzVJ>E2ckL@eYy!%5PS?ID2h&juOWXs>h8cg_blgub{r&buvk< zHO+*gB!@rIugYgVig3Ify!XPfjmrX;agHN)O4PPVch9gc73?i3ADfhYbzbOBM3Uvu zsfd_sCp^DwW+_mq@LQE1wRkghC~xoefM_^!!ji-kO^P~tE{1pbrCKu%miRy-vZs0U zTnD)w+;eM{@sI;m$~s&FQkVYuxj~}Unz;MCDV|8jYFT0R31LmBCCPD`Bpy~=9Ia*p zoK&{sMLOK}8l!wa-S3WSSfiy~`;(vc1}J*}*d(0;gpbO<7gMO3{~z~LNDYF@qbK`S zB*t;C(DOobY89)94oL4?OSWh{%uGvy5yxuzT?n|R4>m26J5Li}Q~W##$n?^-xZ*25 z>`rWEzkW}RY9?YA!wJPrtTV`R|M;<(0R2$GI|T`26D}INwIKIuEZOlg|D~qKViNN) z?{Vg%9$pAbcX56g8qNqE<^yLiB-&>{ln=}OIc~{*9fn*XrTS-A*+rUGj4LuP=G+s( zW8=9J^DifwxkZMs!p3Agk3m?C@5=Ee8p*?EPM=ZrY_{psH_}5JWwq) z??aA28D*_YMFKyME}84ccYyR>D1$+Y>^^t{CW(!&3c^!6ae$|Q8T3y_?p_T-`ZEEkB1!t@F1ZtuOuwu@*@RP=L z{7pJwAL}+|Jv|EFExZEXkhEu@N9T5ozL-r8}OZ43^a zBTEj58u0jsv{Yt`7woOty=P;14Ih7!`sX{G5ZRi2GY8YIH&DX^z!#|JV@)2v6|i~v zK9T@OsN1;afeY84pcza*e2Z?8QT+JV9>hMb*w`m(UG@>5cD?yR8t~xz%opLf%{E0% zR&RDkPf`JUXL|eCmS)_fh4L5Fo~yYDC;r#TfN9OiYJnE>fNN%CsQp^H%SqTY15s6m=1$>VD`hn3xoxlFPe8QnE}4hP;9 zUpEUEHg;6D>Y7c5qC~ZCaRZ&ihEg~C*-;a$Yn!RS>?~>ts8L)7UNtopY`U zCzpSdT8%)PdL1vtBL8OWr-V~?GE*&ffOFI-u^OX@LO0WP4nPSM@6#`q)%+aLb^`zLbTdC9rxo1$h1mfmc{Db7<}rT z99FPP-)g`5QWEJs^%#jE+|I0ywo}BR3AYKu^PS>icpn%){Unz!*FJMy)5wTMds}A< zf0UH7{hF#01CX$t3tY#_bO|7B1m0W4fsYp7a5miMrFmTmFjkkx{ca z0m)`02ifz~o|F9pw-_P*-oWNGkM|>=3u_-p0`t>gmwkPr5cbi2>=%ULu}eOC75DT$ z1TeZj1RIeJ6)*G$b_+TK5eU?-KXE{LtRX~PA^PX);q8m!?wWw|a~SrK{GCl%vJfCHIc6K}5h>f}yb_GlJ_4sC+qC`|S+or7K(yTf!_aO%ep z{%cmrCx?bY=E7xze^t@&RS~4zBSB%5L9L^~uA<3+?rn=f)wzVD>JKYqDc*6>B`{4hRwfM@yMq!Qwxl8>@8h!f5_p3K{6M zd&bgZVsU>TmFD&RA^N+eK{J<@5b3K6ClC$9T$9bbCMBiz_fT!hr*) z^=vXq-P!CKf^S5~+0B2k4FeB)MVElfDf*n`#U!8q066n%OC%ZhB7Xf?WD%czp@=@& z{Pc394Qqp`T}nFi(4p|I#C@m`_&e+ibYhz_1-ktCVW%C2J-88&;Ku(zmTYv3*G)yP zs;1tKU$iXZrrC7Pl9R7!poPA*Y4JhWD(q`97ob~@75Q{x8^F~vm-o2KpGQtT1hlu5 z;rsw}G%9^tLXE_l5!Ku7|FI2MDW^OOD5J!Gy$J%SOe--0h18pLD2pE8pKNrUqtdp& zak^6kXPszZ*1C^B6&iDc42+8tVA=OND}eXK+J^93nN2mi5jdv@T1?kK>ouK=tvN|Y zCOMbG@sDrL1M}36XAP5jc8W1Iircd z85Tc1-A3V5=cqkk;t?g>R67Hv2!%}2AHmTxRExGja5S?>fb6)Y?oVBV9hWO7(lvsgrfP%5)MI^|@m6*3UF9Fg`%#qzYBM5JqPK8X<3F6$309C_mC@RbKCrlJdz9|`ulbAF_?PON=Fod?z zK1eFK?U4Jb^s#|i*c*tF;kPeYX=sx#osjIv*CtXZZoO0QWN*->uzwM)(-$ikrJQE#N@=-iV5YqU=CcL0TQVNAU@l~cvPc7GaV|37?hH*ugO|7&wIy5 z^qTuaj~Xhl%A=id?IZ>T*|Aoh-SFE%Xv$UH?7BssZ!r12&ot20PbLQ40;s6m@}H04 zD4ic{SX!GSz*-?)&3|Y6Da5=CssnuPEFtRL0PWB8{P>h>*@`I4pum|crmuG*sfUm2Aub=n_n`MG4ThtZFA=lX z(~|kwF@-DzIcY*t8t2hp_(8QQy=X$Mp-JA%hdUGpC;;}JFa?7L1;1Zj8l#?4>6>!D z1!MD?Ri&5+h`msDt#_~>NpkedCUL0kzbG;SF)=JEGHg;j&Q&LL3gk0@-@cm+?HNo_^xL(e&rr*Xpm5nzZs z@m=3ZX5X{xzj01lAk;y@k;V0*2Bu#>=f{d7ng0T3cdgYr_v8&4#mF0znc*VtC1&z5 z!batps!yLiRb_cd^&Twkf|{xw5tU(SQLd%K(fvKg-bD~$;$rc)AvTQYT&{ShpFR`3 z6oewzyaQR=t8+LNoa#RKlv=22{;;ci$c{^^kx0>?w{J5NuPaF1B`VBuj-S)gwCVrFPHBOKpz>V>Uew{xmN zdT@LU^A1!|No>?eNTA1$)L?7^KMoE+wrA-Y@d5ku7ytWt3W(c5>$fdzjnmGy{}-H+ zq zDJxvLL(?VuEqf5Aq?(m-MiLJP#p5qfds%UBW4aeDHXQDWOZ!++J3t|?EQq1I%B(%z zBi0jBr(C7}h+GmEoG?cLIy$U7!T#C92Z>b4BhcvTFI}ixliBpN`ugQUNeF=|&ZPKm zLuLqLNuQo*?3B)R1^B?fa-us??eGPz3G-HFXJSXAW{DqntyAI1_>sLm043soXS!dV z!0XIp6|8ZhYi#yGx;t(hKWP+3FpsThqqkNJ7HJ^%7B5gks~v}=#%OFurFs3&D@&g0 z&c#KwLBaStZgXsuUjoLek{O}9*~!>foZ7tq&5h3(e9m^%%}RG(8(f$n@(exo{pGLk ziShHascu)&-<0XaO1}Ica>uDz3C8y3j_W%Wh1Eolb{)w?j)x++g=q%GRg@l>W)g)~ zZIJyu#ITpEUA}>xpZZjq`n6Bd8tPbe{e8+M-;l4u(W*gO*{1b{$^=VQC@O3ogj}5|HS5oDzRO76|3v{URqOO z-@s$36PhB_1=?LTF+6c|wyDHkS%1rhrHXqMPV2J$qAL^^gYd78I9FiW&87YV$YYut zZ1&E=FpEB+h|2U_@Z#ijsw6jccRKFZ=h2&im6L>jv^jf?zI55%Z6M)+LIeYlfdcqm z(BohVUxJw4k;eQ=^6b=e$CZnzTJ+{-S#3+)xmyhs&y8A(Y;V`Xcp!z>^$qIQkV5D? z<)IFE4+m8UPQqUkJL(!@{zR%i8sSd)!Rb1ef3ptm#=%4kQE`nJ;<1^0s-UOJW?L`P zKgG6ALwO=@gbHk{9cKy;Sz$Fg#w<;wD5Z4}Yo6ZnPigL#fR^G))~!N#Lu;2RrMWX* zq!|C(J{7{d+L3ha?t>eqi9|1(o>{&fp^Z522cY2Tr0(x)Ewp2M7x6xSWK9i-A+8Fx zJGn%eP>R3!ni^(>X^Mo>W)iZW0@ssKuC!}BUj?yNh>KjtmyD`ytH)2C$;%uN(7MuZ zbj2UcWl^f&6<_nloh#B2+E3Q&NtIj#l&m3HHNX3DHEGZyJm5$S#g<*tvDzbe4X zgdQJjkal|jdFit6Hvs*`zToH6_(FTWlr?7gDsE83T%J#+I@$6!=v&#Cn)oe73n9?P z7OD3!E?rdP>V6O$8T!rreiKc6wdI(1>dh+YYU|8njjaOZUl}2rz{zhz5^*XZ$-kE2 z!_CI}Y9DsM(s>y=Wm~liy4c1#q%)2U1DZ4F#}B*VvWW6Xqqbc~3t4ON$;0l2(}V6n zzF(x~unxlFPknhEmuh|i`J@D}FdxMQuIf`{<;%CbDGM+)ak~lU<(vf(s08-uL$C)% zEBd|;MbA{U)LIMQtz}BXBO4U2kEJ+@Y90&t7`2z*Qo3)j^=DE1+7!%lYXUTBOmQHH z$18T24=#|Hkuy9=ofwuq=j6+M7I0ZD{^VB&q$v?CDV9~9x=mt-R(1R{bu;( zZ5rrJ7y9t<+0&SAZBM0<~+ImFDg4b zjDKoSqzWlPcz)(gne|WJAs=vQkBuiw)aV#=Y0>%E_Rlj~($DMyN2_Jq95JpXf zzhFx<_eG^$ik`Li{@v-|-fdM5dSdcgbJ=o+QS8_=#1gIkZm=Q}bd}PcZz{QK=N+W$ zo{$C%<9`3%Y!14u$9sip22 zmxC^nIxtPm4ki^S84rG)1y!W+rY4&N#FY6Uqv-MLwg_e34vMckJIx`d=Ew%vY}r9} z5Tj6grun@+H)L&B2SG&L`Y?7#lN=Fl1 z(w{kk9uIcdL4EB9cCMu=`DPcL^P?ME9sbKsTOHyTz44Es4b&{W#+PgE&(^&!v<&s! z#rM3cLGXZjjlJGM?esk}hA{%y^v9!-tX>Ii)uStr;>o~K0(`sf^NNVIbr&XA-Jp9j z5Ti}v0)Y7G2SLW(`G z-wxGt%GPnblE=`r??Dy7-q1|of8?q=$NP0Sni zSSgAd(v>DW(A)yeUwv+uIg^B*uj0RZ3ex4@@csCoaxBB&Ei=FBIt*F9d)ib9&RzO9 zP^q`t6U0=W1sdHl2~8KT{u!(*-81A=pS}Apao-e_R67MgzJ7@d;-L4arfemD9SIG?-7F(^9w#mtaS8I}<`r!Zcz=1W@#QW>s^#|PpZI3p(q-vWV z-DQ$g1mib3zi6^MY?D(~uWeXEWm~EtlNV%9+&2U#Ata4dZ_TiuHYdlIfw>Gf^pR&k z>9g3SKGz>+jj6O!Oc}Wxj2GmISoV6}_P2&^9|ws2B^<-7qd;Bpol;u3!kN}-e!kB& zSqazVvEyI!9A}~{tq4eLEH8Pb<4ZW5d09WrL5GXDP!jOgyt1+Ow@*jru026^Nj-GI z+;3@gyZ|oIP`jS7SVe{empW43;ahdn`B1KOPUAFs#N=d+sJK7HW(J;R><9lx2{&f9eZ=>h9G(M&?NV$-Q%$tPJE z!a=9t`aymA2l5WDXYs(*p4Ri}@m+H)iNOjxYeMCnU3Z>7df>%n&|V+s`d8Q(MD$@(-hVy?3;&VG5tsLF?L)4N1p08Ifa` zuHk-^Zw3Y#Y>yOg`fbqUtzy6_Y;LX+AJ{^L#}7g=R}^%-y0S61*T-j7d3tC zSShp6f_{6foDH%5Y5pE>WA9P*`vSW)QQGsD3J6e2s?x(zX)(@7m$TuSF2|f>KvgOc z6&26hl77!RpBMRi4Zlkcv<#i&NtC?;hFwBBXrEWcD^exFI+|vGZA|!k{t$D3N$n9F z@e>2Hr@*oLET?K)p54c(#yBVY@EzzVm5_sva)X#4%JZs|RTy(!es7d5k*GjWQBbC% zrtgZ|$u@xkFj4J<1ef81h(`lw4;a`%>?V5pIt(2yWA#QRcpM^a)A4%kvpwnUyZWF1 zAspLLP-htLd2$y>gXH}TC;+xjkJ=?ZwykbLgdI~#Xi=Z=6Yop240YhR4)FTyGF8JvYa zN3!}mux0A8hbQ?n4C4!4Dq^TP)7~_FvAF#4T}BdaBNsu`4U@BkIifzeJf0h2G9kzA z@}PLVA*MCWrf1^nyv^CxpO_6wyFaH9(A(bkt|BX11Lfuq@bnnkK8iZ)6s&P&_mV$P zK6iYSC?o@1(ix0TiX^Ko&4%39*3{KDH~7}o3>xr@`+aL*UYO{6Y_7#fOv}r$ZB5Ka z^^N~Yt!>ca1XJv9UoJY(Pxy`7_3+-GEwZ*5s@sr=NcD<*b{9IA=n`qrXzGtwYYKef zMc;8S9y66vPR{(}FCWe_3O07!NG~bWyCq$CIZZ`M(j2Hb6?b~nvbuDkV%yBvDUHyS z-$SI4Idesee;rx5W=}x&AW8&_j}@}k_=yyylpcEY@4K>)$d{*TzVC|;_g<2A|Ey#3 zQ+vdI!Ru9yF@4o+waE`wkJ~#6!LK0^*drDO09Z%6K9@O|ITE{f zE<`pVIrdE26zfmQ+#e@B21|%#pL$&Yv=y>)<8_!~F(BqHy7}G4>!2eV6-upPyFGCg zB~JVPs_^3Fc0{cp%yYX~BXZsjT<>@!jre>bxpF&`YXX&Y1Wz4ZXBSz&e6Zxoj)bGa z^tCR0={G@5#DeUxgG=OX>i&=O0)wJSWZfsFN2D`+g07KTxq_ift^IB3(|$M0MHX;O zp9{4hkqZuc76F*AG!W@6)2Y`DCGK}5Rv3tCSuf)p)gJ!H)~z-?;nKQd|Aap_3KNcV z%|EN;Bfj3u1*&^>eyg(dypB%W`eV(⋙yV_(j^KQ0-gae-*(XKm34o*SJ-F1vzu) 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
0mj4R!`a!3UDhA!qZkb%;l9U6ti^ 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 { + 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 + } + + var body: some View { + Chart { + // Threshold lines + RuleMark(y: .value("Low", convertBG(config.lowLine))) + .foregroundStyle(.red.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 3])) + RuleMark(y: .value("High", convertBG(config.highLine))) + .foregroundStyle(.yellow.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 3])) + + // BG history points + ForEach(bgHistory, id: \.timestamp) { reading in + PointMark( + x: .value("Time", reading.timestamp), + y: .value("BG", convertBG(Double(reading.bgValue))) + ) + .symbolSize(12) + .foregroundStyle(pointColor(bgValue: reading.bgValue)) + } + + // Prediction lines + if let status = loopStatus { + if status.isOpenAPS { + predictionMarks(values: status.ztPredictions, start: status.predictionStart, color: Color(red: 0.443, green: 0.380, blue: 0.937)) + predictionMarks(values: status.iobPredictions, start: status.predictionStart, color: Color(red: 0.118, green: 0.588, blue: 0.988)) + predictionMarks(values: status.cobPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.757, blue: 0.271)) + predictionMarks(values: status.uamPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.518, blue: 0.271)) + } else { + predictionMarks(values: status.predictions, start: status.predictionStart, color: .purple) + } + } + } + .chartYScale(domain: yDomain) + .chartXScale(domain: visibleStart ... visibleEnd) + .chartXAxis { + AxisMarks(values: .stride(by: .hour)) { value in + AxisGridLine() + 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 + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3)) + 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)) + } + } + } + } + .focusable() + .digitalCrownRotation($timeOffset, from: -300, through: 12, by: 1, sensitivity: .low, isHapticFeedbackEnabled: false) + .onChange(of: timeOffset) { newValue in + let snapped = newValue.rounded() + if snapped != lastHapticOffset { + lastHapticOffset = snapped + timeOffset = snapped + WKInterfaceDevice.current().play(.click) + } + } + } + + private func pointColor(bgValue: Int) -> Color { + let bg = Double(bgValue) + if bg <= config.lowLine { return .red } + if bg >= config.highLine { return .yellow } + return .green + } + + @ChartContentBuilder + private func predictionMarks(values: [Double]?, start: Date?, color: Color) -> 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)) + ) + .foregroundStyle(color.opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 2])) + .interpolationMethod(.catmullRom) + } + } + } +} diff --git a/LoopFollowWatch/BGFetcher.swift b/LoopFollowWatch/BGFetcher.swift new file mode 100644 index 000000000..c533cb826 --- /dev/null +++ b/LoopFollowWatch/BGFetcher.swift @@ -0,0 +1,742 @@ +// LoopFollow +// BGFetcher.swift + +import Combine +import Foundation + +class BGFetcher: ObservableObject { + @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" + + private var timer: Timer? + private var dexSessionToken: String? + private var profileLoaded = false + private var basalSchedule: [(timeAsSeconds: Double, value: Double)] = [] + private var profileTimezone: TimeZone = .current + + 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" + + func start(config: WatchConfig) { + stop() + profileLoaded = false + fetch(config: config) + timer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in + self?.fetch(config: config) + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + 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) + if config.hasNightscoutURL { + fetchNightscout(config: config) + fetchDeviceStatus(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" + } + } + } 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) { + 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 { 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() + } + + func fetchDeviceStatusAt(config: WatchConfig, date: Date) { + 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 { 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) + } + } + + 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? + + // 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 + } + + // 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 + ) + + DispatchQueue.main.async { + self.loopStatus = status + self.updateScheduledBasal(for: timestamp) + } + } + + 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? + + 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) + } + } + + // 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] + } + + // 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 + ) + + DispatchQueue.main.async { + self.loopStatus = status + self.updateScheduledBasal(for: timestamp) + } + } + + // 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 } + } + + 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] + + // 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 } + basalSchedule = schedule + } + + // Extract timezone + if let tz = defaultStore?["timezone"] as? String, + let timezone = TimeZone(identifier: tz) { + profileTimezone = 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.overridePresets = presets + // Update scheduled basal for current time + self.updateScheduledBasal(for: Date()) + } + } + + // 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 { + self.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" + } + } + + 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..ef9511869 --- /dev/null +++ b/LoopFollowWatch/BGReading.swift @@ -0,0 +1,68 @@ +// 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 seconds = Int(Date().timeIntervalSince(timestamp)) + let minutes = seconds / 60 + if minutes < 1 { + return "just now" + } + return "\(minutes) min ago" + } + + 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/ContentView.swift b/LoopFollowWatch/ContentView.swift new file mode 100644 index 000000000..3d41f0a6c --- /dev/null +++ b/LoopFollowWatch/ContentView.swift @@ -0,0 +1,252 @@ +// LoopFollow +// ContentView.swift + +import SwiftUI +import WatchKit + +struct ContentView: View { + @ObservedObject var sessionManager: WatchSessionManager + @ObservedObject var bgFetcher: BGFetcher + + @State private var now = Date() + @State private var timeOffset: Double = 0 + @State private var showReloadCheck = false + @State private var timeTravelDebounce: Timer? + let minuteTimer = Timer.publish(every: 30, 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 right edge (most recent visible time) of the chart view (timeOffset in 5-min units) + private var viewCenterTime: Date { + Date().addingTimeInterval(timeOffset * 300) + } + + 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) + } + } + } 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(minuteTimer) { _ in now = Date() } + .onChange(of: timeOffset) { _ in + timeTravelDebounce?.invalidate() + if isTimeTravel, let config = sessionManager.config { + timeTravelDebounce = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in + bgFetcher.fetchDeviceStatusAt(config: config, date: viewCenterTime) + } + } + } + } + + private var displayReading: BGReading? { + if isTimeTravel { + return bgFetcher.bgHistory.min(by: { + abs($0.timestamp.timeIntervalSince(viewCenterTime)) < abs($1.timestamp.timeIntervalSince(viewCenterTime)) + }) + } + return bgFetcher.currentBG + } + + @ViewBuilder + private func mainView(reading: BGReading, config: WatchConfig) -> some View { + let bgColor = reading.bgColor(lowLine: config.lowLine, highLine: config.highLine) + let stale = isTimeTravel ? false : reading.isStale + + ZStack { + VStack(spacing: 2) { + // Row 1: Large BG + trend arrow + delta + HStack(alignment: .center, spacing: 2) { + Text(reading.bgText(units: config.units)) + .font(.system(size: 60, weight: .bold, design: .default)) + .foregroundColor(bgColor) + .minimumScaleFactor(0.5) + .lineLimit(1) + + Text(reading.direction) + .font(.system(size: 44, weight: .bold, design: .default)) + .foregroundColor(bgColor) + + Spacer() + + if !reading.deltaText(units: config.units).isEmpty { + VStack(spacing: 0) { + Text(reading.deltaText(units: config.units)) + .font(.system(size: 32, weight: .bold, design: .default)) + .foregroundColor(.secondary) + .lineLimit(1) + Text(config.units) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal, 4) + + // Row 2: Gray capsule bar — IOB, COB, Basal, checkmark, freshness + HStack(spacing: 5) { + if let status = displayStatus { + if let iob = status.iob { + Text(String(format: "%.1fU", iob)) + } + if let cob = status.cob { + Text(String(format: "%.0fg", cob)) + } + if let currentBasal = status.basalRate { + let scheduled = bgFetcher.scheduledBasal ?? currentBasal + let diff = currentBasal - scheduled + if abs(diff) < 0.005 { + Text("⏷0") + } else if diff > 0 { + Text(String(format: "⏶%.1f", diff)) + } else { + Text(String(format: "⏷%.1f", abs(diff))) + } + } + } + + Spacer() + + if bgFetcher.lastError == nil { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(.green) + } else { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(.red) + } + + Text(freshnessText(reading: reading)) + .foregroundColor(isTimeTravel ? .blue : .white) + .onTapGesture(count: 2) { + bgFetcher.reload() + } + } + .font(.system(size: 13, weight: .medium, design: .default)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.15)) + .cornerRadius(10) + .padding(.horizontal, 2) + + // Row 3: Chart + BGChartView( + bgHistory: bgFetcher.bgHistory, + loopStatus: bgFetcher.loopStatus, + config: config, + timeOffset: $timeOffset + ) + .frame(maxHeight: .infinity) + + // 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: 12)) + .foregroundColor(.purple) + .padding(.top, 1) + } + if status.tempTargetActive, let text = status.tempTargetText { + Text("Temp Target: \(text)") + .font(.system(size: 12)) + .foregroundColor(.orange) + .padding(.top, 1) + } + } + + // Source footer — shows actual data source + HStack(spacing: 5) { + Circle() + .fill(bgFetcher.lastError == nil ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(bgFetcher.activeSource.isEmpty ? "---" : bgFetcher.activeSource) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.top, 1) + } + .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 + } + + @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 { + if isTimeTravel { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + return formatter.string(from: reading.timestamp) + } + return reading.minAgoText + } +} diff --git a/LoopFollowWatch/CrownConfirmView.swift b/LoopFollowWatch/CrownConfirmView.swift new file mode 100644 index 000000000..8f863649c --- /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 first, then scroll the Digital Crown through a full rotation 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) + .transition(.scale) + } 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)) + if tapped { + Text("Scroll") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + } + .frame(width: 70, height: 70) + .contentShape(Circle()) + .onTapGesture { + if !tapped && !confirmed { + withAnimation { tapped = true } + WKInterfaceDevice.current().play(.click) + } + } + + // Instruction text + if confirmed { + Text("Sent!") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.green) + } else if tapped { + Text("Scroll crown \(label)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } else { + Text("Tap wheel, then scroll \(label)") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .focusable(tapped && !confirmed) + .digitalCrownRotation( + $progress, + from: 0, + through: fullRotation, + by: 0.02, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: true + ) + .onChange(of: progress) { newValue in + guard tapped else { + // Reset if somehow triggered before tap + 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/Info.plist b/LoopFollowWatch/Info.plist new file mode 100644 index 000000000..719cbd8d2 --- /dev/null +++ b/LoopFollowWatch/Info.plist @@ -0,0 +1,28 @@ + + + + + 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) + + diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift new file mode 100644 index 000000000..532124569 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -0,0 +1,47 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import SwiftUI +import WatchKit + +class ExtensionDelegate: NSObject, WKApplicationDelegate { + func applicationDidFinishLaunching() { + WatchSessionManager.shared.startSession() + } +} + +@main +struct LoopFollowWatchApp: App { + @WKApplicationDelegateAdaptor(ExtensionDelegate.self) var delegate + + @StateObject private var sessionManager = WatchSessionManager.shared + @StateObject private var bgFetcher = BGFetcher() + + var body: some Scene { + WindowGroup { + TabView { + ContentView(sessionManager: sessionManager, bgFetcher: bgFetcher) + + if let config = sessionManager.config, config.remoteEnabled { + RemoteControlView(config: config, bgFetcher: bgFetcher) + } + } + .tabViewStyle(.page) + .onChange(of: sessionManager.config) { newConfig in + if let config = newConfig, config.hasAnySource { + bgFetcher.start(config: config) + } else { + bgFetcher.stop() + } + } + .onAppear { + 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..9defc0577 --- /dev/null +++ b/LoopFollowWatch/LoopStatus.swift @@ -0,0 +1,26 @@ +// 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? +} 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..26c6c1aac --- /dev/null +++ b/LoopFollowWatch/RemoteControlView.swift @@ -0,0 +1,70 @@ +// LoopFollow +// RemoteControlView.swift + +import SwiftUI + +struct RemoteControlView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + + private let columns = [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ] + + var body: some View { + NavigationStack { + LazyVGrid(columns: columns, spacing: 8) { + NavigationLink { + WatchBolusView(config: config) + } label: { + RemoteTile(icon: "💧", label: "Bolus", color: .blue) + } + .buttonStyle(.plain) + + NavigationLink { + WatchMealView(config: config) + } label: { + RemoteTile(icon: "🍽️", label: "Meal", color: .yellow) + } + .buttonStyle(.plain) + + NavigationLink { + WatchOverrideView(config: config, bgFetcher: bgFetcher) + } label: { + RemoteTile(icon: "⚡", label: "Override", color: .purple) + } + .buttonStyle(.plain) + + NavigationLink { + WatchTempTargetView(config: config) + } label: { + RemoteTile(icon: "🎯", label: "Temp", color: .pink) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 4) + .padding(.top, 2) + } + } +} + +private struct RemoteTile: View { + let icon: String + let label: String + let color: Color + + var body: some View { + VStack(spacing: 4) { + Text(icon) + .font(.system(size: 30)) + Text(label) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .frame(height: 72) + .background(color.opacity(0.3)) + .cornerRadius(12) + } +} diff --git a/LoopFollowWatch/WatchBolusView.swift b/LoopFollowWatch/WatchBolusView.swift new file mode 100644 index 000000000..59bc0b8db --- /dev/null +++ b/LoopFollowWatch/WatchBolusView.swift @@ -0,0 +1,93 @@ +// LoopFollow +// WatchBolusView.swift + +import SwiftUI +import WatchKit + +struct WatchBolusView: View { + let config: WatchConfig + @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 + + /// 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 { + VStack(spacing: 6) { + if let result = resultMessage { + Text(result) + .font(.system(size: 14)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + } else if showConfirm { + Text(String(format: "%.2f U", confirmedAmount)) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.blue) + + CrownConfirmView(label: "to deliver") { + sendBolus() + } + } else { + Text("💧 Bolus") + .font(.system(size: 16, weight: .semibold)) + + Text(String(format: "%.2f U", amount)) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(.blue) + + Text("Max: \(String(format: "%.1f", config.maxBolus))U") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Button("Confirm") { + if amount > 0 { + confirmedAmount = amount + showConfirm = true + } + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(amount <= 0) + } + } + .focusable(!showConfirm) + .digitalCrownRotation( + Binding( + get: { showConfirm ? 0 : rawCrown }, + set: { if !showConfirm { rawCrown = $0 } } + ), + from: 0, + through: config.maxBolus / 0.25, + by: 0.01, + sensitivity: .low, + isContinuous: false, + isHapticFeedbackEnabled: false // no built-in haptic — we fire manually + ) + .onChange(of: rawCrown) { _ in + let current = amount + if current != lastHapticAmount { + lastHapticAmount = current + WKInterfaceDevice.current().play(.click) + } + } + } + + private func sendBolus() { + WatchRemoteService.sendBolus(amount: confirmedAmount, config: config) { success, error in + if success { + resultMessage = "Bolus sent!" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} diff --git a/LoopFollowWatch/WatchConfig.swift b/LoopFollowWatch/WatchConfig.swift new file mode 100644 index 000000000..ab36f68d0 --- /dev/null +++ b/LoopFollowWatch/WatchConfig.swift @@ -0,0 +1,115 @@ +// 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 + + // Nightscout write auth + var nsWriteAuth: Bool + + 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, + "nsWriteAuth": nsWriteAuth, + ] + } + + 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 ?? "" + nsWriteAuth = dict["nsWriteAuth"] as? Bool ?? false + } + + func saveToDefaults() { + let defaults = UserDefaults.standard + defaults.set(toDictionary(), forKey: "watchConfig") + } + + 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..888269488 --- /dev/null +++ b/LoopFollowWatch/WatchMealView.swift @@ -0,0 +1,83 @@ +// LoopFollow +// WatchMealView.swift + +import SwiftUI +import WatchKit + +struct WatchMealView: View { + let config: WatchConfig + @State private var carbs: Double = 0 + @State private var lastHapticCarbs: Int = 0 + @State private var confirmedCarbs: Int = 0 + @State private var showConfirm = false + @State private var resultMessage: String? + @State private var isError = false + + var body: some View { + VStack(spacing: 6) { + if let result = resultMessage { + Text(result) + .font(.system(size: 14)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + } else if showConfirm { + Text("\(confirmedCarbs)g carbs") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(.yellow) + + CrownConfirmView(label: "to send meal") { + sendMeal() + } + } else { + Text("🍽️ Meal") + .font(.system(size: 16, weight: .semibold)) + + Text("\(Int(carbs))g") + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundColor(.yellow) + + Text("Max: \(Int(config.maxCarbs))g") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Button("Confirm") { + if carbs > 0 { + confirmedCarbs = Int(carbs) + showConfirm = true + } + } + .buttonStyle(.borderedProminent) + .tint(.yellow) + .disabled(carbs <= 0) + } + } + .focusable(!showConfirm) + .digitalCrownRotation( + $carbs, + from: 0, + through: config.maxCarbs, + by: 1, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: false + ) + .onChange(of: carbs) { _ in + let current = Int(carbs) + if current != lastHapticCarbs { + lastHapticCarbs = current + WKInterfaceDevice.current().play(.click) + } + } + } + + private func sendMeal() { + WatchRemoteService.sendMeal(carbs: confirmedCarbs, config: config) { success, error in + if success { + resultMessage = "Meal sent!" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} diff --git a/LoopFollowWatch/WatchOverrideView.swift b/LoopFollowWatch/WatchOverrideView.swift new file mode 100644 index 000000000..581bb24db --- /dev/null +++ b/LoopFollowWatch/WatchOverrideView.swift @@ -0,0 +1,108 @@ +// LoopFollow +// WatchOverrideView.swift + +import SwiftUI + +struct WatchOverrideView: View { + let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + @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 + + var body: some View { + ScrollView { + VStack(spacing: 6) { + if let result = resultMessage { + Text(result) + .font(.system(size: 14)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + } else 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 { + Text("⚡ 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: 13, weight: .medium)) + Spacer() + if let pct = preset.percentage { + Text(String(format: "%.0f%%", pct)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(.borderedProminent) + .tint(.purple.opacity(0.4)) + } + } + + Divider() + + Button("Cancel Active Override") { + showCancelConfirm = true + } + .foregroundColor(.red) + } + } + } + } + + private func sendOverride(name: String) { + WatchRemoteService.sendOverride(name: name, config: config) { success, error in + if success { + resultMessage = "Override activated!" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } + + private func cancelOverride() { + WatchRemoteService.cancelOverride(config: config) { success, error in + if success { + resultMessage = "Override cancelled" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} diff --git a/LoopFollowWatch/WatchRemoteService.swift b/LoopFollowWatch/WatchRemoteService.swift new file mode 100644 index 000000000..2a76a7a20 --- /dev/null +++ b/LoopFollowWatch/WatchRemoteService.swift @@ -0,0 +1,368 @@ +// LoopFollow +// WatchRemoteService.swift + +import CryptoKit +import Foundation + +class WatchRemoteService { + + // 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 + ) + 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, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + switch config.remoteType { + case "Trio Remote Control": + let payload = TRCPayload( + user: config.trcUser, + commandType: "meal", + timestamp: Date().timeIntervalSince1970, + carbs: carbs + ) + sendTRCCommand(payload: payload, config: config, completion: completion) + case "Nightscout": + let body: [String: Any] = [ + "enteredBy": "LoopFollow Watch", + "eventType": "Meal Bolus", + "carbs": carbs, + "created_at": ISO8601DateFormatter().string(from: Date()), + ] + 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 + ) + 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 + ) + 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 + ) + 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 + ) + 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 overrideName: String? + + enum CodingKeys: String, CodingKey { + case user + case commandType = "command_type" + case timestamp + case bolusAmount = "bolus_amount" + case target, duration, carbs, overrideName + } + } + + 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 + guard let jwt = signJWT(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) { data, 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 + 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 + + 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 + guard error == nil, let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let jwt = json["token"] as? String + else { + // Fallback: use token directly as API secret hash + completion(config.nsToken) + return + } + completion(jwt) + }.resume() + } +} diff --git a/LoopFollowWatch/WatchSessionManager.swift b/LoopFollowWatch/WatchSessionManager.swift new file mode 100644 index 000000000..fea8ab1ac --- /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? + + private override 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 activationState: 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(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + handleReceivedConfig(applicationContext) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + handleReceivedConfig(userInfo) + } + + func session(_ 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..c5e49cb0d --- /dev/null +++ b/LoopFollowWatch/WatchTempTargetView.swift @@ -0,0 +1,208 @@ +// LoopFollow +// WatchTempTargetView.swift + +import SwiftUI + +struct WatchTempTargetView: View { + let config: WatchConfig + @State private var mode: ViewMode = .menu + @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 + + enum ViewMode { + case menu, custom + } + + enum EditField { + case target, duration + } + + /// The value bound to the crown depending on which field is being edited + private var crownBinding: Binding { + switch editingField { + case .target: + return $customTarget + case .duration: + return $customDuration + } + } + + private var crownRange: ClosedRange { + 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 { + ScrollView { + VStack(spacing: 8) { + if let result = resultMessage { + Text(result) + .font(.system(size: 14)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + } else if showConfirm { + Text("\(pendingTarget) \(config.units == "mmol/L" ? "mmol/L" : "mg/dL") for \(pendingDuration)m") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.pink) + + CrownConfirmView(label: "to set target") { + sendTempTarget() + } + } else if mode == .custom { + Text("🎯 Custom Target") + .font(.system(size: 14, weight: .semibold)) + + // Target row — tappable to select for crown editing + 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 ? .pink : .primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(editingField == .target ? Color.pink.opacity(0.15) : Color.clear) + .cornerRadius(8) + } + .buttonStyle(.plain) + + // Duration row — tappable to select for crown editing + 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 ? .pink : .primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(editingField == .duration ? Color.pink.opacity(0.15) : Color.clear) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Tap a field, then scroll crown") + .font(.system(size: 9)) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + Button("Back") { + mode = .menu + } + .font(.system(size: 12)) + + Button("Set") { + pendingTarget = Int(customTarget) + pendingDuration = Int(customDuration) + showConfirm = true + } + .buttonStyle(.borderedProminent) + .tint(.pink) + .font(.system(size: 12)) + } + } else { + // Menu mode + Text("🎯 Temp Target") + .font(.system(size: 14, weight: .semibold)) + + // Presets + Button("Exercise: 150 / 60m") { + pendingTarget = 150 + pendingDuration = 60 + showConfirm = true + } + .buttonStyle(.borderedProminent) + .tint(.pink.opacity(0.6)) + + Button("Eating Soon: 80 / 60m") { + pendingTarget = 80 + pendingDuration = 60 + showConfirm = true + } + .buttonStyle(.borderedProminent) + .tint(.pink.opacity(0.6)) + + // Custom + Divider() + + Button("Custom...") { + mode = .custom + editingField = .target + } + .buttonStyle(.borderedProminent) + .tint(.pink) + + Divider() + + // Cancel + Button("Cancel Active") { + cancelTarget() + } + .foregroundColor(.red) + } + } + } + .focusable(mode == .custom && !showConfirm) + .digitalCrownRotation( + crownBinding, + from: crownRange.lowerBound, + through: crownRange.upperBound, + by: crownStep, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: true + ) + } + + private func sendTempTarget() { + WatchRemoteService.sendTempTarget(target: pendingTarget, duration: pendingDuration, config: config) { success, error in + if success { + resultMessage = "Target set!" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } + + private func cancelTarget() { + WatchRemoteService.cancelTempTarget(config: config) { success, error in + if success { + resultMessage = "Target cancelled" + } else { + resultMessage = error ?? "Failed" + isError = true + } + } + } +} From 41982e7ca39698fa98cce5c4d728cc6362c86ad0 Mon Sep 17 00:00:00 2001 From: Auggie Date: Thu, 7 May 2026 23:16:44 -0400 Subject: [PATCH 2/7] Complications, visual cleanup, speed/data improvements - Complications for rectangle, circle, also bolus/meal/override/temp deep link shortcuts - Dynamic BG color integrated throughout experiences - Stats view on watch - LoopStatus view on watch - Cleaned up crown rotation requirements --- LoopFollow.xcodeproj/project.pbxproj | 380 ++++++--- .../xcschemes/LoopFollowWatch.xcscheme | 8 +- LoopFollow/Watch/PhoneSessionManager.swift | 4 +- LoopFollowWatch/BGChartView.swift | 338 +++++++- LoopFollowWatch/BGDynamicColor.swift | 35 + LoopFollowWatch/BGFetcher.swift | 740 +++++++++++++++++- LoopFollowWatch/BGReading.swift | 11 +- LoopFollowWatch/CelebrationOverlay.swift | 331 ++++++++ LoopFollowWatch/ContentView.swift | 289 +++++-- LoopFollowWatch/CrownCaptureView.swift | 34 + LoopFollowWatch/CrownConfirmView.swift | 54 +- LoopFollowWatch/CrownRotationModifier.swift | 38 + LoopFollowWatch/FollowStatusView.swift | 462 +++++++++++ LoopFollowWatch/Info.plist | 4 + LoopFollowWatch/LoopFollowWatch.entitlements | 10 + LoopFollowWatch/LoopFollowWatchApp.swift | 102 ++- LoopFollowWatch/LoopStatus.swift | 15 + LoopFollowWatch/NavigationRouter.swift | 48 ++ LoopFollowWatch/RemoteControlView.swift | 75 +- LoopFollowWatch/StatsView.swift | 293 +++++++ LoopFollowWatch/Treatment.swift | 32 + LoopFollowWatch/WatchBolusView.swift | 355 ++++++++- LoopFollowWatch/WatchConfig.swift | 18 + LoopFollowWatch/WatchMealView.swift | 297 +++++-- LoopFollowWatch/WatchOverrideView.swift | 101 ++- LoopFollowWatch/WatchRemoteService.swift | 36 +- LoopFollowWatch/WatchTempTargetView.swift | 437 +++++++---- LoopFollowWidgets/ActionShortcutWidgets.swift | 114 +++ LoopFollowWidgets/BGDynamicColor.swift | 35 + LoopFollowWidgets/BGLiveActivity.swift | 111 +++ LoopFollowWidgets/BGTimelineProvider.swift | 80 ++ .../CircularComplicationView.swift | 90 +++ LoopFollowWidgets/Info.plist | 29 + .../LoopFollowWidgets.entitlements | 10 + LoopFollowWidgets/LoopFollowWidgets.swift | 50 ++ .../RectangularComplicationView.swift | 378 +++++++++ LoopFollowWidgets/WidgetData.swift | 46 ++ .../WidgetNightscoutFetcher.swift | 164 ++++ 38 files changed, 5096 insertions(+), 558 deletions(-) create mode 100644 LoopFollowWatch/BGDynamicColor.swift create mode 100644 LoopFollowWatch/CelebrationOverlay.swift create mode 100644 LoopFollowWatch/CrownCaptureView.swift create mode 100644 LoopFollowWatch/CrownRotationModifier.swift create mode 100644 LoopFollowWatch/FollowStatusView.swift create mode 100644 LoopFollowWatch/LoopFollowWatch.entitlements create mode 100644 LoopFollowWatch/NavigationRouter.swift create mode 100644 LoopFollowWatch/StatsView.swift create mode 100644 LoopFollowWatch/Treatment.swift create mode 100644 LoopFollowWidgets/ActionShortcutWidgets.swift create mode 100644 LoopFollowWidgets/BGDynamicColor.swift create mode 100644 LoopFollowWidgets/BGLiveActivity.swift create mode 100644 LoopFollowWidgets/BGTimelineProvider.swift create mode 100644 LoopFollowWidgets/CircularComplicationView.swift create mode 100644 LoopFollowWidgets/Info.plist create mode 100644 LoopFollowWidgets/LoopFollowWidgets.entitlements create mode 100644 LoopFollowWidgets/LoopFollowWidgets.swift create mode 100644 LoopFollowWidgets/RectangularComplicationView.swift create mode 100644 LoopFollowWidgets/WidgetData.swift create mode 100644 LoopFollowWidgets/WidgetNightscoutFetcher.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 2825b80d8..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,15 +428,12 @@ 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 */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; FCFEECA02488157B00402A7F /* Chart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEEC9F2488157B00402A7F /* Chart.swift */; }; FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCFEECA1248857A600402A7F /* SettingsViewController.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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -450,6 +451,13 @@ 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 */; @@ -482,6 +490,17 @@ 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 */ @@ -537,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 = ""; }; @@ -727,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 = ""; }; @@ -896,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 = ""; }; @@ -904,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 = ""; }; @@ -913,8 +935,6 @@ FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; FCFEEC9F2488157B00402A7F /* Chart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chart.swift; sourceTree = ""; }; FCFEECA1248857A600402A7F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -925,6 +945,13 @@ ); target = BB01000000000003000000AA /* LoopFollowWatch */; }; + CC01000000000011000000AA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CC01000000000003000000AA /* LoopFollowWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -932,6 +959,7 @@ 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 */ @@ -945,32 +973,48 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { + BB01000000000006000000AA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - FC9788112485969B00A7906C /* Frameworks */ = { + CC01000000000006000000AA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, - 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - BB01000000000006000000AA /* Frameworks */ = { + DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FC9788112485969B00A7906C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */, + 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* 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 = ( @@ -1697,6 +1741,7 @@ FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, + 115EAFAA2FAD881100BF4FAF /* Recovered References */, ); sourceTree = ""; }; @@ -1805,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" */; @@ -1859,28 +1950,6 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; - BB01000000000003000000AA /* LoopFollowWatch */ = { - isa = PBXNativeTarget; - buildConfigurationList = BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; - buildPhases = ( - BB01000000000004000000AA /* Sources */, - BB01000000000006000000AA /* Frameworks */, - BB01000000000005000000AA /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - BB01000000000002000000AA /* LoopFollowWatch */, - ); - name = LoopFollowWatch; - packageProductDependencies = ( - ); - productName = LoopFollowWatch; - productReference = BB01000000000001000000AA /* LoopFollowWatch.app */; - productType = "com.apple.product-type.application"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1894,6 +1963,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + CC01000000000003000000AA = { + CreatedOnToolsVersion = 16.3; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1922,6 +1994,7 @@ DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, BB01000000000003000000AA /* LoopFollowWatch */, + CC01000000000003000000AA /* LoopFollowWidgets */, ); }; /* End PBXProject section */ @@ -1934,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; @@ -2071,13 +2158,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BB01000000000005000000AA /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2178,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; @@ -2482,13 +2576,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - BB01000000000004000000AA /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2504,6 +2591,11 @@ target = BB01000000000003000000AA /* LoopFollowWatch */; targetProxy = BB0100000000000D000000AA /* PBXContainerItemProxy */; }; + CC01000000000012000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CC01000000000003000000AA /* LoopFollowWidgets */; + targetProxy = CC01000000000010000000AA /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -2542,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; @@ -2594,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; @@ -2634,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 = { @@ -2643,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)", @@ -2676,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)", @@ -2828,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 = ( @@ -2853,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 = ( @@ -2869,53 +3061,6 @@ }; 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_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_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_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_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; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2928,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 = ( @@ -2955,15 +3118,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - BB01000000000007000000AA /* Debug */, - BB01000000000008000000AA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ diff --git a/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme index 3ff5bdd29..fae7819ae 100644 --- a/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme +++ b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme @@ -39,10 +39,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - + - + = 2 } + @FocusState private var chartFocused: Bool // timeOffset is in units of 5 minutes (1 BG reading), snapped to integers private var snappedOffset: Double { @@ -18,13 +35,40 @@ struct BGChartView: View { } private var visibleStart: Date { - Date().addingTimeInterval(-3 * 3600 + snappedOffset * 300) + 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 @@ -36,50 +80,152 @@ struct BGChartView: View { 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 { - // Threshold lines - RuleMark(y: .value("Low", convertBG(config.lowLine))) - .foregroundStyle(.red.opacity(0.4)) - .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 3])) - RuleMark(y: .value("High", convertBG(config.highLine))) - .foregroundStyle(.yellow.opacity(0.4)) - .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 3])) - - // BG history points - ForEach(bgHistory, id: \.timestamp) { reading in - PointMark( - x: .value("Time", reading.timestamp), - y: .value("BG", convertBG(Double(reading.bgValue))) - ) - .symbolSize(12) - .foregroundStyle(pointColor(bgValue: reading.bgValue)) + 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) + } + } } - // Prediction lines + // 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 { - if status.isOpenAPS { - predictionMarks(values: status.ztPredictions, start: status.predictionStart, color: Color(red: 0.443, green: 0.380, blue: 0.937)) - predictionMarks(values: status.iobPredictions, start: status.predictionStart, color: Color(red: 0.118, green: 0.588, blue: 0.988)) - predictionMarks(values: status.cobPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.757, blue: 0.271)) - predictionMarks(values: status.uamPredictions, start: status.predictionStart, color: Color(red: 1.0, green: 0.518, blue: 0.271)) + 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) + 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)) { value in - AxisGridLine() + 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 - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3)) AxisValueLabel { if let v = value.as(Double.self) { Text(config.units == "mmol/L" ? String(format: "%.0f", v) : String(format: "%.0f", v)) @@ -88,8 +234,13 @@ struct BGChartView: View { } } } + .chartBackground { proxy in + sparklineOverlay(proxy: proxy) + } .focusable() - .digitalCrownRotation($timeOffset, from: -300, through: 12, by: 1, sensitivity: .low, isHapticFeedbackEnabled: false) + .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 { @@ -98,22 +249,46 @@ struct BGChartView: View { 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 { - let bg = Double(bgValue) - if bg <= config.lowLine { return .red } - if bg >= config.highLine { return .yellow } - return .green + return bgDynamicColor(Double(bgValue)) } @ChartContentBuilder - private func predictionMarks(values: [Double]?, start: Date?, color: Color) -> some ChartContent { + 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)) + y: .value("BG", convertBG(value)), + series: .value("Series", series) ) .foregroundStyle(color.opacity(0.7)) .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 2])) @@ -121,4 +296,99 @@ struct BGChartView: View { } } } + + // 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..819db8818 --- /dev/null +++ b/LoopFollowWatch/BGDynamicColor.swift @@ -0,0 +1,35 @@ +// LoopFollow +// BGDynamicColor.swift +// +// Maps a BG value (mg/dL) to a hue-based color. +// Red (hue 0°) at ≤55, Green (hue 120°) at 100, Purple (hue 270°) at ≥220. +// Interpolates linearly through the hue spectrum between those anchors. + +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 index c533cb826..909437b79 100644 --- a/LoopFollowWatch/BGFetcher.swift +++ b/LoopFollowWatch/BGFetcher.swift @@ -3,8 +3,76 @@ 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: Self.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? @@ -13,23 +81,89 @@ class BGFetcher: ObservableObject { @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 var basalSchedule: [(timeAsSeconds: Double, value: Double)] = [] - private var profileTimezone: TimeZone = .current 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) - timer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in - self?.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() { @@ -37,6 +171,24 @@ class BGFetcher: ObservableObject { 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 } @@ -49,10 +201,11 @@ class BGFetcher: ObservableObject { func fetch(config: WatchConfig) { currentConfig = config - // Always fetch from Nightscout if available (BG entries + devicestatus + profile) + // 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) } @@ -152,6 +305,7 @@ class BGFetcher: ObservableObject { } else { self.lastError = nil self.activeSource = "Nightscout" + self.updateWidgetData() } } } catch { @@ -193,6 +347,7 @@ class BGFetcher: ObservableObject { } func fetchDeviceStatusAt(config: WatchConfig, date: Date) { + DispatchQueue.main.async { self.statusMatchesScroll = false } var components = URLComponents(string: config.nsURL) components?.path = "/api/v1/devicestatus.json" @@ -223,7 +378,10 @@ class BGFetcher: ObservableObject { guard let json = try? JSONSerialization.jsonObject(with: data, options: []), let entries = json as? [[String: Any]], let lastEntry = entries.first - else { return } + else { + DispatchQueue.main.async { self.statusMatchesScroll = true } + return + } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] @@ -236,6 +394,21 @@ class BGFetcher: ObservableObject { } } + /// 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? @@ -244,6 +417,23 @@ class BGFetcher: ObservableObject { 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 @@ -278,6 +468,11 @@ class BGFetcher: ObservableObject { 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 { @@ -304,12 +499,19 @@ class BGFetcher: ObservableObject { ztPredictions: nil, iobPredictions: nil, cobPredictions: nil, uamPredictions: nil, isOpenAPS: false, - tempTargetActive: false, tempTargetText: nil + 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() } } @@ -324,6 +526,25 @@ class BGFetcher: ObservableObject { 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] @@ -356,6 +577,18 @@ class BGFetcher: ObservableObject { } } + // 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 { @@ -381,6 +614,29 @@ class BGFetcher: ObservableObject { 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 @@ -401,12 +657,19 @@ class BGFetcher: ObservableObject { ztPredictions: ztPredictions, iobPredictions: iobPredictions, cobPredictions: cobPredictions, uamPredictions: uamPredictions, isOpenAPS: true, - tempTargetActive: tempTargetActive, tempTargetText: tempTargetText + 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() } } @@ -433,6 +696,118 @@ class BGFetcher: ObservableObject { 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" @@ -479,6 +854,17 @@ class BGFetcher: ObservableObject { ?? 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)] = [] @@ -488,13 +874,49 @@ class BGFetcher: ObservableObject { schedule.append((timeAsSeconds: timeAsSeconds, value: value)) } schedule.sort { $0.timeAsSeconds < $1.timeAsSeconds } - basalSchedule = schedule + 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) { - profileTimezone = timezone + newTimezone = timezone } // Trio overrides — JSON key is "overridePresets" at profile top level @@ -547,9 +969,304 @@ class BGFetcher: ObservableObject { 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 } } @@ -708,6 +1425,7 @@ class BGFetcher: ObservableObject { self.isReloading = false self.lastError = nil self.activeSource = "Dexcom" + self.updateWidgetData() } } diff --git a/LoopFollowWatch/BGReading.swift b/LoopFollowWatch/BGReading.swift index ef9511869..5301da232 100644 --- a/LoopFollowWatch/BGReading.swift +++ b/LoopFollowWatch/BGReading.swift @@ -43,12 +43,11 @@ struct BGReading { } var minAgoText: String { - let seconds = Int(Date().timeIntervalSince(timestamp)) - let minutes = seconds / 60 - if minutes < 1 { - return "just now" - } - return "\(minutes) min ago" + 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 { diff --git a/LoopFollowWatch/CelebrationOverlay.swift b/LoopFollowWatch/CelebrationOverlay.swift new file mode 100644 index 000000000..9f41ea83a --- /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 index 3d41f0a6c..c7e2af3f9 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -3,23 +3,27 @@ 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 = 0 + @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? - let minuteTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect() + @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 right edge (most recent visible time) of the chart view (timeOffset in 5-min units) + /// The inspected point — 70% through the visible chart window private var viewCenterTime: Date { - Date().addingTimeInterval(timeOffset * 300) + Date().addingTimeInterval(timeOffset * 300 - zoomHours * 3600 * 0.3) } var body: some View { @@ -43,6 +47,7 @@ struct ContentView: View { Text("Loading...") .font(.system(size: 14)) .foregroundColor(.secondary) + .offset(y: -20) } } } else { @@ -57,147 +62,243 @@ struct ContentView: View { } } } - .onReceive(minuteTimer) { _ in now = Date() } + .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.3, repeats: false) { _ in + 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() + } + } } - private var displayReading: BGReading? { - if isTimeTravel { - return bgFetcher.bgHistory.min(by: { - abs($0.timestamp.timeIntervalSince(viewCenterTime)) < abs($1.timestamp.timeIntervalSince(viewCenterTime)) - }) + /// Refresh data if the last BG reading is older than 5 minutes + private func refreshIfStale() { + guard let reading = bgFetcher.currentBG else { + bgFetcher.reload() + return } - return bgFetcher.currentBG + 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 = reading.bgColor(lowLine: config.lowLine, highLine: config.highLine) + let bgColor = bgDynamicColor(Double(reading.bgValue)) let stale = isTimeTravel ? false : reading.isStale ZStack { - VStack(spacing: 2) { - // Row 1: Large BG + trend arrow + delta + 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: 60, weight: .bold, design: .default)) + .font(.system(size: 48, weight: .regular, design: .default)) .foregroundColor(bgColor) - .minimumScaleFactor(0.5) .lineLimit(1) + .fixedSize() - Text(reading.direction) - .font(.system(size: 44, weight: .bold, design: .default)) - .foregroundColor(bgColor) - - Spacer() + 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 { - VStack(spacing: 0) { + if !reading.deltaText(units: config.units).isEmpty { Text(reading.deltaText(units: config.units)) - .font(.system(size: 32, weight: .bold, design: .default)) - .foregroundColor(.secondary) + .font(.system(size: 20, weight: .medium, design: .default)) + .foregroundColor(.white) .lineLimit(1) - Text(config.units) - .font(.system(size: 10)) - .foregroundColor(.secondary) + .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 capsule bar — IOB, COB, Basal, checkmark, freshness - HStack(spacing: 5) { + // 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 - let diff = currentBasal - scheduled - if abs(diff) < 0.005 { - Text("⏷0") - } else if diff > 0 { - Text(String(format: "⏶%.1f", diff)) - } else { - Text(String(format: "⏷%.1f", abs(diff))) - } + Text(String(format: "%.1f\u{2192}%.1fU/h", scheduled, currentBasal)) + .foregroundColor(dataColor) } } - - Spacer() - - if bgFetcher.lastError == nil { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 12)) - .foregroundColor(.green) - } else { - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 12)) - .foregroundColor(.red) - } - - Text(freshnessText(reading: reading)) - .foregroundColor(isTimeTravel ? .blue : .white) - .onTapGesture(count: 2) { - bgFetcher.reload() - } } - .font(.system(size: 13, weight: .medium, design: .default)) + .font(.system(size: 16, weight: .medium, design: .default)) .foregroundColor(.white) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(0.5) .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.white.opacity(0.15)) - .cornerRadius(10) - .padding(.horizontal, 2) + .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 + ) + ) + ) - // Row 3: Chart + // 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 + 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: 12)) + .font(.system(size: 10)) .foregroundColor(.purple) - .padding(.top, 1) } if status.tempTargetActive, let text = status.tempTargetText { Text("Temp Target: \(text)") - .font(.system(size: 12)) + .font(.system(size: 10)) .foregroundColor(.orange) - .padding(.top, 1) } } - - // Source footer — shows actual data source - HStack(spacing: 5) { - Circle() - .fill(bgFetcher.lastError == nil ? Color.green : Color.red) - .frame(width: 8, height: 8) - Text(bgFetcher.activeSource.isEmpty ? "---" : bgFetcher.activeSource) - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - .padding(.top, 1) } + .padding(.bottom, 10) .opacity(stale ? 0.6 : 1.0) // Reload overlay @@ -222,6 +323,23 @@ struct ContentView: View { 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 { @@ -242,11 +360,18 @@ struct ContentView: View { } private func freshnessText(reading: BGReading) -> String { - if isTimeTravel { + // 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) } - return reading.minAgoText + // 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 index 8f863649c..201be7779 100644 --- a/LoopFollowWatch/CrownConfirmView.swift +++ b/LoopFollowWatch/CrownConfirmView.swift @@ -5,7 +5,7 @@ import SwiftUI import WatchKit /// Reusable crown-rotation confirmation component. -/// User must TAP the wheel icon first, then scroll the Digital Crown through a full rotation to confirm. +/// User must TAP the wheel icon once, then scroll the Digital Crown to confirm. struct CrownConfirmView: View { let label: String let onConfirm: () -> Void @@ -42,48 +42,49 @@ struct CrownConfirmView: View { Image(systemName: "checkmark") .font(.system(size: 28, weight: .bold)) .foregroundColor(.green) - .transition(.scale) } 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)) - if tapped { - Text("Scroll") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } + Text(tapped ? "Scroll" : "Tap") + .font(.system(size: 10)) + .foregroundColor(tapped ? .blue : .gray.opacity(0.5)) } } } - .frame(width: 70, height: 70) - .contentShape(Circle()) + .frame(width: 80, height: 80) + .padding(.horizontal, 8) .onTapGesture { if !tapped && !confirmed { - withAnimation { tapped = true } + withAnimation(.none) { tapped = true } WKInterfaceDevice.current().play(.click) } } - // Instruction text - if confirmed { - Text("Sent!") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.green) - } else if tapped { - Text("Scroll crown \(label)") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.primary) - .multilineTextAlignment(.center) - } else { - Text("Tap wheel, then scroll \(label)") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + // 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(tapped && !confirmed) + .focusable() .digitalCrownRotation( $progress, from: 0, @@ -95,7 +96,6 @@ struct CrownConfirmView: View { ) .onChange(of: progress) { newValue in guard tapped else { - // Reset if somehow triggered before tap progress = 0 return } 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..34d94d2c7 --- /dev/null +++ b/LoopFollowWatch/FollowStatusView.swift @@ -0,0 +1,462 @@ +// LoopFollow +// FollowStatusView.swift +// +// Full-screen sheet shown when the user taps the loop-status icon. Two tabs: +// "Device Status" mirrors the iPhone "Follow Status" view (LOOP / OVERRIDE / +// REASON / PUMP / SITE / TODAY / UPDATED), and "Profile" lists the active +// profile schedules. All data is sourced from BGFetcher state already +// downloaded for the main view — no new fetches. + +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 index 719cbd8d2..07306cf27 100644 --- a/LoopFollowWatch/Info.plist +++ b/LoopFollowWatch/Info.plist @@ -24,5 +24,9 @@ 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 index 532124569..5f90b019a 100644 --- a/LoopFollowWatch/LoopFollowWatchApp.swift +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -2,11 +2,89 @@ // LoopFollowWatchApp.swift import SwiftUI +import UserNotifications import WatchKit +import WidgetKit + +class ExtensionDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { -class ExtensionDelegate: NSObject, WKApplicationDelegate { 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( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) } } @@ -15,18 +93,31 @@ struct LoopFollowWatchApp: App { @WKApplicationDelegateAdaptor(ExtensionDelegate.self) var delegate @StateObject private var sessionManager = WatchSessionManager.shared - @StateObject private var bgFetcher = BGFetcher() + @StateObject private var bgFetcher = BGFetcher.shared + @StateObject private var router = NavigationRouter() var body: some Scene { WindowGroup { - TabView { + 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) + 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) @@ -35,6 +126,9 @@ struct LoopFollowWatchApp: App { } } .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 { diff --git a/LoopFollowWatch/LoopStatus.swift b/LoopFollowWatch/LoopStatus.swift index 9defc0577..b80024679 100644 --- a/LoopFollowWatch/LoopStatus.swift +++ b/LoopFollowWatch/LoopStatus.swift @@ -23,4 +23,19 @@ struct LoopStatus { // 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..1f331f7a3 --- /dev/null +++ b/LoopFollowWatch/NavigationRouter.swift @@ -0,0 +1,48 @@ +// LoopFollow +// NavigationRouter.swift +// +// Handles deep link URL parsing and drives programmatic navigation +// from complication shortcuts into the watch app's screens. + +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/RemoteControlView.swift b/LoopFollowWatch/RemoteControlView.swift index 26c6c1aac..c6261de5c 100644 --- a/LoopFollowWatch/RemoteControlView.swift +++ b/LoopFollowWatch/RemoteControlView.swift @@ -3,9 +3,12 @@ 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), @@ -15,36 +18,56 @@ struct RemoteControlView: View { var body: some View { NavigationStack { LazyVGrid(columns: columns, spacing: 8) { - NavigationLink { - WatchBolusView(config: config) + Button { + router.activeDestination = .bolus } label: { - RemoteTile(icon: "💧", label: "Bolus", color: .blue) + RemoteTile(icon: "drop.fill", label: "Bolus", color: .blue) } .buttonStyle(.plain) - NavigationLink { - WatchMealView(config: config) + Button { + router.activeDestination = .meal } label: { - RemoteTile(icon: "🍽️", label: "Meal", color: .yellow) + RemoteTile(icon: "fork.knife", label: "Meal", color: .yellow) } .buttonStyle(.plain) - NavigationLink { - WatchOverrideView(config: config, bgFetcher: bgFetcher) + Button { + router.activeDestination = .override } label: { - RemoteTile(icon: "⚡", label: "Override", color: .purple) + RemoteTile(icon: "bolt.fill", label: "Override", color: .purple) } .buttonStyle(.plain) - NavigationLink { - WatchTempTargetView(config: config) + Button { + router.activeDestination = .tempTarget } label: { - RemoteTile(icon: "🎯", label: "Temp", color: .pink) + 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) + } + } } } } @@ -56,15 +79,35 @@ private struct RemoteTile: View { var body: some View { VStack(spacing: 4) { - Text(icon) - .font(.system(size: 30)) + 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(color.opacity(0.3)) - .cornerRadius(12) + .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..4e305e2a4 --- /dev/null +++ b/LoopFollowWatch/StatsView.swift @@ -0,0 +1,293 @@ +// LoopFollow +// StatsView.swift +// +// Third watch page (swipe right from Remote). Mirrors the iPhone +// LoopFollow stats block: pie chart for Low / In Range / High +// distribution, plus Avg BG, Est A1C, and Std Dev. Computed over the +// last 24 hours of the bgHistory cache that BGFetcher already holds +// for the chart (~300 readings ≈ 25h from Nightscout or Dexcom Share). +// +// Formulas match LoopFollow/Controllers/Stats.swift exactly: +// - Low/High thresholds are inclusive (<= lowLine, >= highLine) +// - Population standard deviation (divide by N, not N-1) +// - NGSP A1C: (avgBG + 46.7) / 28.7 +// IFCC A1C and per-user alt formulas live on the iPhone via +// Storage.useIFCC; we default to NGSP here to keep WatchConfig small. + +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 index 59bc0b8db..ba729ee89 100644 --- a/LoopFollowWatch/WatchBolusView.swift +++ b/LoopFollowWatch/WatchBolusView.swift @@ -4,14 +4,28 @@ 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 /// The displayed amount, snapped to 0.05U increments private var amount: Double { @@ -21,56 +35,115 @@ struct WatchBolusView: View { } var body: some View { - VStack(spacing: 6) { + VStack(spacing: 0) { if let result = resultMessage { - Text(result) - .font(.system(size: 14)) - .foregroundColor(isError ? .red : .green) - .multilineTextAlignment(.center) + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() + } + CelebrationOverlay(isActive: $showCelebration) + } } else if showConfirm { - Text(String(format: "%.2f U", confirmedAmount)) - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(.blue) + confirmSummary + .padding(.bottom, 12) - CrownConfirmView(label: "to deliver") { - sendBolus() + CrownConfirmView(label: confirmedAmount > 0 ? "to deliver" : "to send meal") { + sendBolusAndMeal() } + } else { - Text("💧 Bolus") - .font(.system(size: 16, weight: .semibold)) + 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) + // Extra padding so the tap target doesn't bleed + // into the system back button zone on small watches. + .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: 36, weight: .bold, design: .rounded)) + .font(.system(size: 60, weight: .bold, design: .rounded)) .foregroundColor(.blue) + .lineLimit(1) + .minimumScaleFactor(0.7) - Text("Max: \(String(format: "%.1f", config.maxBolus))U") - .font(.system(size: 11)) - .foregroundColor(.secondary) - - Button("Confirm") { - if amount > 0 { - confirmedAmount = amount - showConfirm = true + 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) + .disabled(amount <= 0 && pendingMeal == nil) } } - .focusable(!showConfirm) - .digitalCrownRotation( - Binding( - get: { showConfirm ? 0 : rawCrown }, - set: { if !showConfirm { rawCrown = $0 } } - ), + .modifier(CrownRotationModifier( + isActive: !showConfirm && resultMessage == nil, + value: $rawCrown, from: 0, through: config.maxBolus / 0.25, by: 0.01, - sensitivity: .low, - isContinuous: false, - isHapticFeedbackEnabled: false // no built-in haptic — we fire manually - ) + sensitivity: .low + )) .onChange(of: rawCrown) { _ in let current = amount if current != lastHapticAmount { @@ -78,16 +151,232 @@ struct WatchBolusView: View { 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 launched directly (not from meal entry), clear any stale pending carbs + if pendingMeal == nil { + bgFetcher.pendingCarbs = 0 + } + bgFetcher.updateRecommendedBolus() + } + .onDisappear { + bgFetcher.pendingCarbs = 0 + } + } + + @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 { - resultMessage = "Bolus sent!" + 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 index ab36f68d0..43dfee427 100644 --- a/LoopFollowWatch/WatchConfig.swift +++ b/LoopFollowWatch/WatchConfig.swift @@ -31,6 +31,11 @@ struct WatchConfig: Equatable { // 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 } @@ -75,6 +80,9 @@ struct WatchConfig: Equatable { "trcProductionEnv": trcProductionEnv, "trcUser": trcUser, "nsWriteAuth": nsWriteAuth, + "mealWithFatProtein": mealWithFatProtein, + "maxProtein": maxProtein, + "maxFat": maxFat, ] } @@ -99,11 +107,21 @@ struct WatchConfig: Equatable { trcProductionEnv = dict["trcProductionEnv"] as? Bool ?? false trcUser = dict["trcUser"] as? String ?? "" 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? { diff --git a/LoopFollowWatch/WatchMealView.swift b/LoopFollowWatch/WatchMealView.swift index 888269488..bc75fe0fc 100644 --- a/LoopFollowWatch/WatchMealView.swift +++ b/LoopFollowWatch/WatchMealView.swift @@ -6,78 +6,263 @@ import WatchKit struct WatchMealView: View { let config: WatchConfig + @ObservedObject var bgFetcher: BGFetcher + var popToRoot: (() -> Void)? @State private var carbs: Double = 0 - @State private var lastHapticCarbs: Int = 0 - @State private var confirmedCarbs: Int = 0 - @State private var showConfirm = false - @State private var resultMessage: String? - @State private var isError = false + @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 - var body: some View { - VStack(spacing: 6) { - if let result = resultMessage { - Text(result) - .font(.system(size: 14)) - .foregroundColor(isError ? .red : .green) - .multilineTextAlignment(.center) - } else if showConfirm { - Text("\(confirmedCarbs)g carbs") - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundColor(.yellow) + 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) + } - CrownConfirmView(label: "to send meal") { - sendMeal() + /// 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 } - } else { - Text("🍽️ Meal") - .font(.system(size: 16, weight: .semibold)) + }, + 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 + } + } + ) + } - Text("\(Int(carbs))g") - .font(.system(size: 36, weight: .bold, design: .rounded)) - .foregroundColor(.yellow) + 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 + } + } - Text("Max: \(Int(config.maxCarbs))g") - .font(.system(size: 11)) - .foregroundColor(.secondary) + 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 + ) + } - Button("Confirm") { - if carbs > 0 { - confirmedCarbs = Int(carbs) - showConfirm = true + 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 } } - .buttonStyle(.borderedProminent) - .tint(.yellow) - .disabled(carbs <= 0) } } - .focusable(!showConfirm) - .digitalCrownRotation( - $carbs, - from: 0, - through: config.maxCarbs, - by: 1, - sensitivity: .medium, - isContinuous: false, - isHapticFeedbackEnabled: false - ) - .onChange(of: carbs) { _ in - let current = Int(carbs) - if current != lastHapticCarbs { - lastHapticCarbs = current - WKInterfaceDevice.current().play(.click) + .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 func sendMeal() { - WatchRemoteService.sendMeal(carbs: confirmedCarbs, config: config) { success, error in - if success { - resultMessage = "Meal sent!" - } else { - resultMessage = error ?? "Failed" - isError = true + 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 index 581bb24db..ec9e0d0b8 100644 --- a/LoopFollowWatch/WatchOverrideView.swift +++ b/LoopFollowWatch/WatchOverrideView.swift @@ -6,21 +6,32 @@ 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 let result = resultMessage { - Text(result) - .font(.system(size: 14)) - .foregroundColor(isError ? .red : .green) - .multilineTextAlignment(.center) - } else if showConfirm, let override = selectedOverride { + if showConfirm, let override = selectedOverride { Text(override.name) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.purple) @@ -43,7 +54,38 @@ struct WatchOverrideView: View { cancelOverride() } } else { - Text("⚡ Overrides") + // 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 { @@ -59,35 +101,52 @@ struct WatchOverrideView: View { } label: { HStack { Text(preset.name) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 15, weight: .medium)) Spacer() if let pct = preset.percentage { Text(String(format: "%.0f%%", pct)) - .font(.system(size: 11)) + .font(.system(size: 12)) .foregroundColor(.secondary) } } + .padding(.horizontal, 10) + .padding(.vertical, 14) + .background(Color.purple.opacity(0.55)) + .cornerRadius(8) } - .buttonStyle(.borderedProminent) - .tint(.purple.opacity(0.4)) + .buttonStyle(.plain) } } - - Divider() - - Button("Cancel Active Override") { - showCancelConfirm = true - } - .foregroundColor(.red) } } } + } + } + } + + /// 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 @@ -99,6 +158,12 @@ struct WatchOverrideView: View { 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 index 2a76a7a20..efbdfb945 100644 --- a/LoopFollowWatch/WatchRemoteService.swift +++ b/LoopFollowWatch/WatchRemoteService.swift @@ -3,9 +3,23 @@ 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) { @@ -31,23 +45,31 @@ class WatchRemoteService { } } - static func sendMeal(carbs: Int, config: WatchConfig, completion: @escaping (Bool, String?) -> Void) { + 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": - let payload = TRCPayload( + var payload = TRCPayload( user: config.trcUser, commandType: "meal", timestamp: Date().timeIntervalSince1970, carbs: carbs ) + payload.protein = protein + payload.fat = fat + if let entryTime = entryTime { + payload.scheduledTime = entryTime.timeIntervalSince1970 + } sendTRCCommand(payload: payload, config: config, completion: completion) case "Nightscout": - let body: [String: Any] = [ + var body: [String: Any] = [ "enteredBy": "LoopFollow Watch", "eventType": "Meal Bolus", "carbs": carbs, - "created_at": ISO8601DateFormatter().string(from: Date()), + "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") @@ -144,14 +166,18 @@ class WatchRemoteService { var target: Int? var duration: Int? var carbs: Int? + var protein: Int? + var fat: Int? var overrideName: String? + var scheduledTime: TimeInterval? enum CodingKeys: String, CodingKey { case user case commandType = "command_type" case timestamp case bolusAmount = "bolus_amount" - case target, duration, carbs, overrideName + case target, duration, carbs, protein, fat, overrideName + case scheduledTime = "scheduled_time" } } diff --git a/LoopFollowWatch/WatchTempTargetView.swift b/LoopFollowWatch/WatchTempTargetView.swift index c5e49cb0d..a6ad8aafa 100644 --- a/LoopFollowWatch/WatchTempTargetView.swift +++ b/LoopFollowWatch/WatchTempTargetView.swift @@ -3,9 +3,181 @@ import SwiftUI +private let tempColor = Color(red: 0.2, green: 0.9, blue: 0.1) + struct WatchTempTargetView: View { let config: WatchConfig - @State private var mode: ViewMode = .menu + @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 @@ -14,31 +186,36 @@ struct WatchTempTargetView: View { @State private var pendingDuration: Int = 0 @State private var resultMessage: String? @State private var isError = false - - enum ViewMode { - case menu, custom - } + @State private var showCelebration = false enum EditField { case target, duration } - /// The value bound to the crown depending on which field is being edited private var crownBinding: Binding { - switch editingField { - case .target: - return $customTarget - case .duration: - return $customDuration - } + 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 + case .target: return 60...300 + case .duration: return 5...480 } } @@ -50,155 +227,123 @@ struct WatchTempTargetView: View { } var body: some View { - ScrollView { - VStack(spacing: 8) { - if let result = resultMessage { - Text(result) - .font(.system(size: 14)) - .foregroundColor(isError ? .red : .green) - .multilineTextAlignment(.center) - } else if showConfirm { - Text("\(pendingTarget) \(config.units == "mmol/L" ? "mmol/L" : "mg/dL") for \(pendingDuration)m") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.pink) - - CrownConfirmView(label: "to set target") { - sendTempTarget() - } - } else if mode == .custom { - Text("🎯 Custom Target") - .font(.system(size: 14, weight: .semibold)) - - // Target row — tappable to select for crown editing - 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 ? .pink : .primary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(editingField == .target ? Color.pink.opacity(0.15) : Color.clear) - .cornerRadius(8) - } - .buttonStyle(.plain) - - // Duration row — tappable to select for crown editing - 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 ? .pink : .primary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(editingField == .duration ? Color.pink.opacity(0.15) : Color.clear) - .cornerRadius(8) - } - .buttonStyle(.plain) - - Text("Tap a field, then scroll crown") - .font(.system(size: 9)) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - Button("Back") { - mode = .menu - } - .font(.system(size: 12)) - - Button("Set") { - pendingTarget = Int(customTarget) - pendingDuration = Int(customDuration) - showConfirm = true - } - .buttonStyle(.borderedProminent) - .tint(.pink) - .font(.system(size: 12)) + Group { + if let result = resultMessage { + ZStack { + VStack { + Spacer() + Text(result) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(isError ? .red : .green) + .multilineTextAlignment(.center) + Spacer() } - } else { - // Menu mode - Text("🎯 Temp Target") - .font(.system(size: 14, weight: .semibold)) - - // Presets - Button("Exercise: 150 / 60m") { - pendingTarget = 150 - pendingDuration = 60 - showConfirm = true - } - .buttonStyle(.borderedProminent) - .tint(.pink.opacity(0.6)) + 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) - Button("Eating Soon: 80 / 60m") { - pendingTarget = 80 - pendingDuration = 60 - showConfirm = true - } - .buttonStyle(.borderedProminent) - .tint(.pink.opacity(0.6)) + CrownConfirmView(label: "to set target") { + sendTempTarget() + } + } else { + Text("Custom Target") + .font(.system(size: 14, weight: .semibold)) - // Custom - Divider() + 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("Custom...") { - mode = .custom - editingField = .target - } - .buttonStyle(.borderedProminent) - .tint(.pink) + 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) - Divider() + Text("Tap a field, then scroll crown") + .font(.system(size: 9)) + .foregroundColor(.secondary) - // Cancel - Button("Cancel Active") { - cancelTarget() + 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) + } } - .foregroundColor(.red) } } } - .focusable(mode == .custom && !showConfirm) - .digitalCrownRotation( - crownBinding, + .modifier(CrownRotationModifier( + isActive: !showConfirm && resultMessage == nil, + value: crownBinding, from: crownRange.lowerBound, through: crownRange.upperBound, by: crownStep, - sensitivity: .medium, - isContinuous: false, - isHapticFeedbackEnabled: true - ) + sensitivity: .medium + )) } - private func sendTempTarget() { - WatchRemoteService.sendTempTarget(target: pendingTarget, duration: pendingDuration, config: config) { success, error in - if success { - resultMessage = "Target set!" - } else { - resultMessage = error ?? "Failed" - isError = true - } + private func autoDismiss() { + let delay = showCelebration ? CelebrationOverlay.displayDuration : 3.0 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + dismiss() } } - private func cancelTarget() { - WatchRemoteService.cancelTempTarget(config: config) { success, error in + private func sendTempTarget() { + WatchRemoteService.sendTempTarget(target: pendingTarget, duration: pendingDuration, config: config) { success, error in if success { - resultMessage = "Target cancelled" + 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..9be0fcbe4 --- /dev/null +++ b/LoopFollowWidgets/ActionShortcutWidgets.swift @@ -0,0 +1,114 @@ +// LoopFollow +// ActionShortcutWidgets.swift +// +// Four static circular complications for quick actions: +// Bolus (drop), Meal (fork & knife), Override (lightning bolt), Temp Target (target). +// Each deep links to the corresponding screen in the watch app. + +import SwiftUI +import WidgetKit + +// MARK: - Shared Timeline (static, never changes) + +struct ActionEntry: TimelineEntry { + let date: Date +} + +struct ActionTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> ActionEntry { + ActionEntry(date: .now) + } + + func getSnapshot(in context: Context, completion: @escaping (ActionEntry) -> Void) { + completion(ActionEntry(date: .now)) + } + + func getTimeline(in context: 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..819db8818 --- /dev/null +++ b/LoopFollowWidgets/BGDynamicColor.swift @@ -0,0 +1,35 @@ +// LoopFollow +// BGDynamicColor.swift +// +// Maps a BG value (mg/dL) to a hue-based color. +// Red (hue 0°) at ≤55, Green (hue 120°) at 100, Purple (hue 270°) at ≥220. +// Interpolates linearly through the hue spectrum between those anchors. + +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..51a0d174b --- /dev/null +++ b/LoopFollowWidgets/BGLiveActivity.swift @@ -0,0 +1,111 @@ +// LoopFollow +// BGLiveActivity.swift +// +// Live Activity definition for real-time BG monitoring on the iPhone Lock Screen +// and Dynamic Island. Uses the same BGComplicationContent view as the watch +// complication, so the visual style is identical. +// +// Gated behind #if os(iOS) because ActivityKit is not available on watchOS. +// When an iOS widget extension target is added, this file will compile and +// BGLiveActivityWidget can be included in the widget bundle. + +#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) { + self.bgValue = data.bgValue + self.direction = data.direction + self.delta = data.delta + self.bgTimestamp = data.bgTimestamp + self.iob = data.iob + self.cob = data.cob + self.basalRate = data.basalRate + self.scheduledBasal = data.scheduledBasal + self.history = data.history + self.units = data.units + self.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..9aece7917 --- /dev/null +++ b/LoopFollowWidgets/BGTimelineProvider.swift @@ -0,0 +1,80 @@ +// LoopFollow +// BGTimelineProvider.swift +// +// Aggressive refresh strategy for near-real-time BG complication updates: +// +// 1. MULTI-ENTRY TIMELINE: Generate 60 entries (one per minute for 1 hour) from a +// single data snapshot. Each entry has its own `date` so WidgetKit displays +// them at the correct time — the staleness counter advances naturally without +// needing a reload. These cost zero budget; only timeline *reloads* count. +// +// 2. APP-DRIVEN RELOADS: BGFetcher calls WidgetCenter.shared.reloadAllTimelines() +// every time new BG data arrives (~every 5 min while foregrounded). Each reload +// generates a fresh batch of 12 entries. +// +// 3. BACKGROUND APP REFRESH: The watch app schedules WKApplicationRefreshBackgroundTask +// every ~15 min. When it fires, the app fetches new data from Nightscout/Dexcom +// and reloads timelines — even when the app isn't on screen. +// +// 4. TIMELINE RELOAD POLICY: .after(next) requests the system reload in 5 minutes. +// Combined with the pre-generated entries, the complication always has something +// fresh to display even if the budget is exhausted for a while. +// +// Net effect: complication updates every ~5 min in practice, with worst-case ~15 min +// from background refresh, matching or exceeding apps like SweetDreams. + +import WidgetKit +import SwiftUI + +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: Context) -> BGEntry { + BGEntry(date: .now, data: nil, displayDate: .now) + } + + func getSnapshot(in context: Context, completion: @escaping (BGEntry) -> Void) { + let entry = BGEntry(date: .now, data: WidgetData.load(), displayDate: .now) + completion(entry) + } + + func getTimeline(in context: 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 .updated(let d): data = d + case .unchanged(let d): data = d + case .failed(let 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..a857215c1 --- /dev/null +++ b/LoopFollowWidgets/CircularComplicationView.swift @@ -0,0 +1,90 @@ +// 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..35cb6822a --- /dev/null +++ b/LoopFollowWidgets/LoopFollowWidgets.swift @@ -0,0 +1,50 @@ +// LoopFollow +// LoopFollowWidgets.swift + +import WidgetKit +import SwiftUI + +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..f73d91fff --- /dev/null +++ b/LoopFollowWidgets/RectangularComplicationView.swift @@ -0,0 +1,378 @@ +// LoopFollow +// RectangularComplicationView.swift +// +// Reusable BG display view for both accessoryRectangular complication and +// future Live Activity usage. Text overlays left side, sparkline fades in +// from left to right with progressive line thickness. No background color. + +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..5b1d481de --- /dev/null +++ b/LoopFollowWidgets/WidgetData.swift @@ -0,0 +1,46 @@ +// LoopFollow +// WidgetData.swift +// Shared data model between the watch app and widget extension. + +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: Self.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..b2f8564b8 --- /dev/null +++ b/LoopFollowWidgets/WidgetNightscoutFetcher.swift @@ -0,0 +1,164 @@ +// LoopFollow +// WidgetNightscoutFetcher.swift +// +// Lightweight Nightscout BG fetcher for the widget extension. Fetches only the +// 3 most recent entries (count=3) and merges any new readings into the cached +// WidgetData. This runs inside getTimeline(), which the system calls every ~5 +// minutes for an active complication — giving us a reliable update path that +// doesn't depend on the watch app's background task budget. + +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) + } +} From 5ba2bc74cb8155526e7ad7332610ec7dc2454ee4 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Fri, 8 May 2026 00:01:31 -0400 Subject: [PATCH 3/7] Fix Nightscout JWT extraction, cache JWTs, send return-notification - Watch was reading top-level `token` from /api/v1/status.json but Nightscout nests it under `authorized.token` - POST treatments now actually authorize - Cached APNS JWTs for 55 min (with 403-invalidation) instead of re-signing P256 on every send - Forwarded LF APNS creds over WCSession so Trio return-notifications land on the iPhone --- LoopFollow/Watch/PhoneSessionManager.swift | 17 +++- LoopFollowWatch/WatchConfig.swift | 22 +++++ LoopFollowWatch/WatchRemoteService.swift | 110 ++++++++++++++++++--- 3 files changed, 137 insertions(+), 12 deletions(-) diff --git a/LoopFollow/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift index 0769af0e2..e156eaeb7 100644 --- a/LoopFollow/Watch/PhoneSessionManager.swift +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -19,7 +19,16 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { } 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, @@ -40,6 +49,12 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { "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, ] } diff --git a/LoopFollowWatch/WatchConfig.swift b/LoopFollowWatch/WatchConfig.swift index 43dfee427..268354785 100644 --- a/LoopFollowWatch/WatchConfig.swift +++ b/LoopFollowWatch/WatchConfig.swift @@ -28,6 +28,16 @@ struct WatchConfig: Equatable { 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 @@ -79,6 +89,12 @@ struct WatchConfig: Equatable { "trcBundleId": trcBundleId, "trcProductionEnv": trcProductionEnv, "trcUser": trcUser, + "lfDeviceToken": lfDeviceToken, + "lfApnsKey": lfApnsKey, + "lfKeyId": lfKeyId, + "lfTeamId": lfTeamId, + "lfBundleId": lfBundleId, + "lfProductionEnv": lfProductionEnv, "nsWriteAuth": nsWriteAuth, "mealWithFatProtein": mealWithFatProtein, "maxProtein": maxProtein, @@ -106,6 +122,12 @@ struct WatchConfig: Equatable { 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 diff --git a/LoopFollowWatch/WatchRemoteService.swift b/LoopFollowWatch/WatchRemoteService.swift index efbdfb945..14ec5d143 100644 --- a/LoopFollowWatch/WatchRemoteService.swift +++ b/LoopFollowWatch/WatchRemoteService.swift @@ -29,7 +29,8 @@ class WatchRemoteService { user: config.trcUser, commandType: "bolus", timestamp: Date().timeIntervalSince1970, - bolusAmount: amount + bolusAmount: amount, + returnNotification: makeReturnNotificationInfo(config: config) ) sendTRCCommand(payload: payload, config: config, completion: completion) case "Nightscout": @@ -53,7 +54,8 @@ class WatchRemoteService { user: config.trcUser, commandType: "meal", timestamp: Date().timeIntervalSince1970, - carbs: carbs + carbs: carbs, + returnNotification: makeReturnNotificationInfo(config: config) ) payload.protein = protein payload.fat = fat @@ -84,7 +86,8 @@ class WatchRemoteService { commandType: "temp_target", timestamp: Date().timeIntervalSince1970, target: target, - duration: duration + duration: duration, + returnNotification: makeReturnNotificationInfo(config: config) ) sendTRCCommand(payload: payload, config: config, completion: completion) case "Nightscout": @@ -109,7 +112,8 @@ class WatchRemoteService { let payload = TRCPayload( user: config.trcUser, commandType: "cancel_temp_target", - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: makeReturnNotificationInfo(config: config) ) sendTRCCommand(payload: payload, config: config, completion: completion) case "Nightscout": @@ -133,7 +137,8 @@ class WatchRemoteService { user: config.trcUser, commandType: "start_override", timestamp: Date().timeIntervalSince1970, - overrideName: name + overrideName: name, + returnNotification: makeReturnNotificationInfo(config: config) ) sendTRCCommand(payload: payload, config: config, completion: completion) default: @@ -147,7 +152,8 @@ class WatchRemoteService { let payload = TRCPayload( user: config.trcUser, commandType: "cancel_override", - timestamp: Date().timeIntervalSince1970 + timestamp: Date().timeIntervalSince1970, + returnNotification: makeReturnNotificationInfo(config: config) ) sendTRCCommand(payload: payload, config: config, completion: completion) default: @@ -170,6 +176,25 @@ class WatchRemoteService { 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 @@ -178,7 +203,31 @@ class WatchRemoteService { 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 { @@ -221,8 +270,8 @@ class WatchRemoteService { return } - // Sign JWT - guard let jwt = signJWT(keyId: config.trcKeyId, teamId: config.trcTeamId, apnsKey: config.trcApnsKey) else { + // 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 } @@ -260,6 +309,11 @@ class WatchRemoteService { 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)") } } @@ -284,6 +338,35 @@ class WatchRemoteService { // 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") @@ -380,12 +463,17 @@ class WatchRemoteService { } 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 jwt = json["token"] as? String + let authorized = json["authorized"] as? [String: Any], + let jwt = authorized["token"] as? String else { - // Fallback: use token directly as API secret hash - completion(config.nsToken) + completion(nil) return } completion(jwt) From f2e4e4f02ca63c6154df2a0b962e9b54879c2693 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Fri, 8 May 2026 10:46:25 -0400 Subject: [PATCH 4/7] Fix SwiftFormat lint errors across Watch and Widget targets - Apply SwiftFormat auto-fixes to resolve CI lint failures - Changes include indentation, file headers, spacing around operators, unused argument markers, brace wrapping, trailing commas, sort imports, and redundant self removal - Covers ~25 files in LoopFollowWatch, LoopFollowWidgets, and LoopFollow/Watch. --- LoopFollow/Watch/PhoneSessionManager.swift | 16 +- LoopFollowWatch/BGChartView.swift | 3 +- LoopFollowWatch/BGDynamicColor.swift | 4 - LoopFollowWatch/BGFetcher.swift | 91 ++++++--- LoopFollowWatch/CelebrationOverlay.swift | 96 +++++----- LoopFollowWatch/ContentView.swift | 13 +- LoopFollowWatch/FollowStatusView.swift | 9 +- LoopFollowWatch/LoopFollowWatchApp.swift | 8 +- LoopFollowWatch/LoopStatus.swift | 22 +-- LoopFollowWatch/NavigationRouter.swift | 9 +- LoopFollowWatch/StatsView.swift | 19 +- LoopFollowWatch/WatchMealView.swift | 10 +- LoopFollowWatch/WatchOverrideView.swift | 154 +++++++-------- LoopFollowWatch/WatchRemoteService.swift | 3 +- LoopFollowWatch/WatchSessionManager.swift | 10 +- LoopFollowWatch/WatchTempTargetView.swift | 6 +- LoopFollowWidgets/ActionShortcutWidgets.swift | 10 +- LoopFollowWidgets/BGDynamicColor.swift | 4 - LoopFollowWidgets/BGLiveActivity.swift | 176 +++++++++--------- LoopFollowWidgets/BGTimelineProvider.swift | 39 +--- .../CircularComplicationView.swift | 1 + LoopFollowWidgets/LoopFollowWidgets.swift | 4 +- .../RectangularComplicationView.swift | 58 +++--- LoopFollowWidgets/WidgetData.swift | 19 +- .../WidgetNightscoutFetcher.swift | 18 +- 25 files changed, 380 insertions(+), 422 deletions(-) diff --git a/LoopFollow/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift index e156eaeb7..4651a5fd7 100644 --- a/LoopFollow/Watch/PhoneSessionManager.swift +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -8,7 +8,7 @@ import WatchConnectivity class PhoneSessionManager: NSObject, WCSessionDelegate { static let shared = PhoneSessionManager() - private override init() { + override private init() { super.init() } @@ -71,15 +71,15 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { // MARK: - WCSessionDelegate - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + func session(_: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error _: Error?) { if activationState == .activated { sendConfig() } } - func sessionDidBecomeInactive(_ session: WCSession) {} + func sessionDidBecomeInactive(_: WCSession) {} - func sessionDidDeactivate(_ session: WCSession) { + func sessionDidDeactivate(_: WCSession) { WCSession.default.activate() } @@ -91,14 +91,14 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { } // Handle Watch requesting config via applicationContext - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { if applicationContext["requestConfig"] != nil { sendConfig() } } // Handle Watch requesting config via sendMessage (with reply) - func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { if message["requestConfig"] != nil { let config = buildConfig() replyHandler(config) @@ -109,14 +109,14 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { } } - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + func session(_: WCSession, didReceiveMessage message: [String: Any]) { if message["requestConfig"] != nil { sendConfig() } } // Handle Watch requesting config via transferUserInfo - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { if userInfo["requestConfig"] != nil { sendConfig() } diff --git a/LoopFollowWatch/BGChartView.swift b/LoopFollowWatch/BGChartView.swift index 7cca501d6..ffdf1e126 100644 --- a/LoopFollowWatch/BGChartView.swift +++ b/LoopFollowWatch/BGChartView.swift @@ -25,6 +25,7 @@ struct BGChartView: View { 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 @@ -317,7 +318,7 @@ struct BGChartView: View { let pts = screenPoints.map(\.point) guard pts.count >= 2 else { return } - for i in 0..<(pts.count - 1) { + for i in 0 ..< (pts.count - 1) { let midBG = Double(screenPoints[i].bgValue + screenPoints[i + 1].bgValue) / 2.0 let segColor = bgDynamicColor(midBG) diff --git a/LoopFollowWatch/BGDynamicColor.swift b/LoopFollowWatch/BGDynamicColor.swift index 819db8818..dc9fc02ba 100644 --- a/LoopFollowWatch/BGDynamicColor.swift +++ b/LoopFollowWatch/BGDynamicColor.swift @@ -1,9 +1,5 @@ // LoopFollow // BGDynamicColor.swift -// -// Maps a BG value (mg/dL) to a hue-based color. -// Red (hue 0°) at ≤55, Green (hue 120°) at 100, Purple (hue 270°) at ≥220. -// Interpolates linearly through the hue spectrum between those anchors. import SwiftUI diff --git a/LoopFollowWatch/BGFetcher.swift b/LoopFollowWatch/BGFetcher.swift index 909437b79..072eeb53c 100644 --- a/LoopFollowWatch/BGFetcher.swift +++ b/LoopFollowWatch/BGFetcher.swift @@ -8,7 +8,7 @@ import WidgetKit // MARK: - Widget Data (shared with LoopFollowWidgets target via UserDefaults) struct WidgetBGPoint: Codable, Hashable { - let value: Int // mg/dL + let value: Int // mg/dL let timestamp: Date } @@ -40,7 +40,7 @@ struct WidgetData: Codable { } static func load() -> WidgetData? { - guard let data = sharedDefaults.data(forKey: Self.storageKey), + guard let data = sharedDefaults.data(forKey: storageKey), let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) else { return nil } return decoded @@ -128,6 +128,7 @@ class BGFetcher: ObservableObject { 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 @@ -422,7 +423,8 @@ class BGFetcher: ObservableObject { // 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 { + let b = uploader["battery"] as? Double + { return Int(b) } return nil @@ -445,25 +447,29 @@ class BGFetcher: ObservableObject { // IOB if let iobData = loopRecord["iob"] as? [String: Any], - let iobValue = iobData["iob"] as? Double { + let iobValue = iobData["iob"] as? Double + { iob = iobValue } // COB if let cobData = loopRecord["cob"] as? [String: Any], - let cobValue = cobData["cob"] as? Double { + let cobValue = cobData["cob"] as? Double + { cob = cobValue } // Basal if let enacted = loopRecord["enacted"] as? [String: Any], - let rate = enacted["rate"] as? Double { + let rate = enacted["rate"] as? Double + { basalRate = rate } // Predictions if let predictData = loopRecord["predicted"] as? [String: Any], - let values = predictData["values"] as? [Double] { + let values = predictData["values"] as? [Double] + { predictions = values predictionStart = timestamp } @@ -475,7 +481,8 @@ class BGFetcher: ObservableObject { // Override (top-level in devicestatus for Loop) if let overrideData = entry["override"] as? [String: Any], - let isActive = overrideData["active"] as? Bool, isActive { + let isActive = overrideData["active"] as? Bool, isActive + { overrideActive = true var oText = "" if let multiplier = overrideData["multiplier"] as? Double { @@ -485,7 +492,8 @@ class BGFetcher: ObservableObject { } if let correction = overrideData["currentCorrectionRange"] as? [String: Any], let minVal = correction["minValue"] as? Double, - let maxVal = correction["maxValue"] as? Double { + let maxVal = correction["maxValue"] as? Double + { oText += " (\(Int(minVal))-\(Int(maxVal)))" } overrideText = oText @@ -533,7 +541,8 @@ class BGFetcher: ObservableObject { // 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 { + let b = uploader["battery"] as? Double + { return Int(b) } return nil @@ -561,7 +570,8 @@ class BGFetcher: ObservableObject { // IOB if let iobData = openapsRecord["iob"] as? [String: Any], - let iobValue = iobData["iob"] as? Double { + let iobValue = iobData["iob"] as? Double + { iob = iobValue } @@ -571,7 +581,8 @@ class BGFetcher: ObservableObject { } 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 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) } @@ -583,7 +594,8 @@ class BGFetcher: ObservableObject { 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 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) } @@ -591,17 +603,20 @@ class BGFetcher: ObservableObject { // Basal from enacted if let enacted = openapsRecord["enacted"] as? [String: Any], - let rate = enacted["rate"] as? Double { + 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] { + 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] { + let predBGs = enacted["predBGs"] as? [String: Any] + { return predBGs } return nil @@ -631,7 +646,8 @@ class BGFetcher: ObservableObject { 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 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) } @@ -644,7 +660,8 @@ class BGFetcher: ObservableObject { if let enacted = openapsRecord["enacted"] as? [String: Any], let targetBG = enacted["target_bg"] as? Double, let currentTarget = enactedOrSuggested?["current_target"] as? Double, - targetBG != currentTarget { + targetBG != currentTarget + { tempTargetActive = true tempTargetText = "\(Int(targetBG)) mg/dL" } @@ -915,7 +932,8 @@ class BGFetcher: ObservableObject { // Extract timezone if let tz = defaultStore?["timezone"] as? String, - let timezone = TimeZone(identifier: tz) { + let timezone = TimeZone(identifier: tz) + { newTimezone = timezone } @@ -944,7 +962,8 @@ class BGFetcher: ObservableObject { // Loop overrides from loopSettings if let loopSettings = profile["loopSettings"] as? [String: Any], - let overridePresetsArray = loopSettings["overridePresets"] 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 @@ -957,7 +976,8 @@ class BGFetcher: ObservableObject { // Also check loopSettings inside default store if presets.isEmpty, let storeLoopSettings = defaultStore?["loopSettings"] as? [String: Any], - let overridePresetsArray = storeLoopSettings["overridePresets"] 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 @@ -1053,7 +1073,8 @@ class BGFetcher: ObservableObject { switch eventType { case "Pump Site Change", "Site Change": if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, - let ts = parseNSDate(dateStr) { + let ts = parseNSDate(dateStr) + { if newCannulaChangeDate == nil || ts > newCannulaChangeDate! { newCannulaChangeDate = ts } @@ -1061,7 +1082,8 @@ class BGFetcher: ObservableObject { case "Sensor Start", "Sensor Change": if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, - let ts = parseNSDate(dateStr) { + let ts = parseNSDate(dateStr) + { if newSensorChangeDate == nil || ts > newSensorChangeDate! { newSensorChangeDate = ts } @@ -1069,7 +1091,8 @@ class BGFetcher: ObservableObject { case "Insulin Change", "Insulin Cartridge Change": if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, - let ts = parseNSDate(dateStr) { + let ts = parseNSDate(dateStr) + { if newInsulinChangeDate == nil || ts > newInsulinChangeDate! { newInsulinChangeDate = ts } @@ -1077,7 +1100,8 @@ class BGFetcher: ObservableObject { case "Correction Bolus", "Bolus", "External Insulin": if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, - let ts = parseNSDate(dateStr) { + 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)) @@ -1092,13 +1116,15 @@ class BGFetcher: ObservableObject { 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 { + 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) { + let ts = parseNSDate(dateStr) + { if let insulin = entry["insulin"] as? Double, insulin > 0 { newTreatments.append(Treatment(timestamp: ts, type: .bolus, value: insulin)) } @@ -1110,7 +1136,8 @@ class BGFetcher: ObservableObject { 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 { + let carbs = entry["carbs"] as? Double, carbs > 0 + { newTreatments.append(Treatment(timestamp: ts, type: .carbs, value: carbs)) } @@ -1126,7 +1153,8 @@ class BGFetcher: ObservableObject { default: // Generic bolus/carb fallback if let dateStr = entry["timestamp"] as? String ?? entry["created_at"] as? String, - let ts = parseNSDate(dateStr) { + let ts = parseNSDate(dateStr) + { if let insulin = entry["insulin"] as? Double, insulin > 0 { newTreatments.append(Treatment(timestamp: ts, type: .bolus, value: insulin)) } @@ -1208,7 +1236,8 @@ class BGFetcher: ObservableObject { // 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) { + let nextStart = parseNSDate(nextDateStr) + { if endDate > nextStart.addingTimeInterval(-60) { endDate = nextStart.addingTimeInterval(-60) } @@ -1415,7 +1444,7 @@ class BGFetcher: ObservableObject { } guard !readings.isEmpty else { - self.fallbackToNightscout(config: config, dexError: "No Dexcom readings") + fallbackToNightscout(config: config, dexError: "No Dexcom readings") return } diff --git a/LoopFollowWatch/CelebrationOverlay.swift b/LoopFollowWatch/CelebrationOverlay.swift index 9f41ea83a..5419040dc 100644 --- a/LoopFollowWatch/CelebrationOverlay.swift +++ b/LoopFollowWatch/CelebrationOverlay.swift @@ -63,7 +63,7 @@ struct CelebrationOverlay: View { /// Returns true roughly every 5–15 sends (≈10% chance per send). static func shouldCelebrate() -> Bool { - return Int.random(in: 1...10) == 1 + return Int.random(in: 1 ... 10) == 1 } /// How long to show the celebration before dismissing (longer than normal 3s). @@ -89,26 +89,26 @@ struct CelebrationOverlay: View { switch animationType { case .confetti: // Two waves of confetti covering the full screen - let wave1: [Particle] = (0..<40).map { _ in + 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), + 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 + 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), + 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 ) @@ -120,19 +120,19 @@ struct CelebrationOverlay: View { 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) + (-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) + 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), + size: Double.random(in: 4 ... 8), rotation: 0, - delay: baseDelay + Double.random(in: 0...0.15), + delay: baseDelay + Double.random(in: 0 ... 0.15), color: [.red, .orange, .yellow, .cyan, .white, .pink, .green].randomElement()!, emoji: "", wave: baseDelay < 1.0 ? 1 : 2 )) @@ -142,16 +142,16 @@ struct CelebrationOverlay: View { case .sparkleRain: // Dense sparkles falling across the full width, two waves - particles = (0..<30).map { i in + particles = (0 ..< 30).map { i in let wave = i < 18 ? 1 : 2 return Particle( - x: Double.random(in: -100...100), + x: Double.random(in: -100 ... 100), y: -120, - targetX: Double.random(in: -100...100), + 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)), + 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 ) @@ -183,45 +183,45 @@ struct CelebrationOverlay: View { 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 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) + 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), + 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 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) + 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), + 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 diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index c7e2af3f9..f9b689904 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -10,7 +10,7 @@ struct ContentView: View { @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 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 @@ -100,6 +100,7 @@ struct ContentView: View { private var visibleStart: Date { Date().addingTimeInterval(-zoomHours * 3600 + timeOffset.rounded() * 300) } + private var visibleEnd: Date { Date().addingTimeInterval(timeOffset.rounded() * 300) } @@ -112,7 +113,8 @@ struct ContentView: View { guard visible.count >= 2, let first = visible.first?.timestamp, let last = visible.last?.timestamp, - last > first else { + 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) @@ -233,7 +235,7 @@ struct ContentView: View { .init(color: .clear, location: 0), .init(color: .white, location: 0.06), .init(color: .white, location: 0.94), - .init(color: .clear, location: 1.0) + .init(color: .clear, location: 1.0), ], startPoint: .leading, endPoint: .trailing @@ -245,7 +247,7 @@ struct ContentView: View { .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) + .init(color: .white.opacity(0.3), location: 1.0), ], startPoint: .top, endPoint: .bottom @@ -362,7 +364,8 @@ struct ContentView: View { 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 { + reading.timestamp != latest.timestamp + { let formatter = DateFormatter() formatter.dateFormat = "h:mm a" return formatter.string(from: reading.timestamp) diff --git a/LoopFollowWatch/FollowStatusView.swift b/LoopFollowWatch/FollowStatusView.swift index 34d94d2c7..37b8402a6 100644 --- a/LoopFollowWatch/FollowStatusView.swift +++ b/LoopFollowWatch/FollowStatusView.swift @@ -1,11 +1,5 @@ // LoopFollow // FollowStatusView.swift -// -// Full-screen sheet shown when the user taps the loop-status icon. Two tabs: -// "Device Status" mirrors the iPhone "Follow Status" view (LOOP / OVERRIDE / -// REASON / PUMP / SITE / TODAY / UPDATED), and "Profile" lists the active -// profile schedules. All data is sourced from BGFetcher state already -// downloaded for the main view — no new fetches. import SwiftUI @@ -163,7 +157,8 @@ private struct DeviceStatusTab: View { private var overrideSection: some View { let s = bgFetcher.loopStatus if (s?.overrideActive == true && s?.overrideText != nil) - || (s?.tempTargetActive == true && s?.tempTargetText != nil) { + || (s?.tempTargetActive == true && s?.tempTargetText != nil) + { VStack(alignment: .leading, spacing: 4) { SectionHeader("Override") if let oText = s?.overrideText, s?.overrideActive == true { diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift index 5f90b019a..0969abbd6 100644 --- a/LoopFollowWatch/LoopFollowWatchApp.swift +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -7,7 +7,6 @@ import WatchKit import WidgetKit class ExtensionDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCenterDelegate { - func applicationDidFinishLaunching() { WatchSessionManager.shared.startSession() let center = UNUserNotificationCenter.current() @@ -32,7 +31,8 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCent // 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 { + config.hasAnySource + { BGFetcher.shared.fetch(config: config) // Give the network requests a few seconds to land, then complete. DispatchQueue.main.asyncAfter(deadline: .now() + 12) { @@ -80,8 +80,8 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, UNUserNotificationCent // Show notifications even when the app is in the foreground func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, + _: UNUserNotificationCenter, + willPresent _: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .sound]) diff --git a/LoopFollowWatch/LoopStatus.swift b/LoopFollowWatch/LoopStatus.swift index b80024679..bbcba35e7 100644 --- a/LoopFollowWatch/LoopStatus.swift +++ b/LoopFollowWatch/LoopStatus.swift @@ -25,17 +25,17 @@ struct LoopStatus { 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 + 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 + 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 index 1f331f7a3..6302ae8bc 100644 --- a/LoopFollowWatch/NavigationRouter.swift +++ b/LoopFollowWatch/NavigationRouter.swift @@ -1,8 +1,5 @@ // LoopFollow // NavigationRouter.swift -// -// Handles deep link URL parsing and drives programmatic navigation -// from complication shortcuts into the watch app's screens. import SwiftUI @@ -27,9 +24,9 @@ class NavigationRouter: ObservableObject { activeDestination = nil switch url.host { - case "bolus": navigateTo(.bolus) - case "meal": navigateTo(.meal) - case "override": navigateTo(.override) + 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) diff --git a/LoopFollowWatch/StatsView.swift b/LoopFollowWatch/StatsView.swift index 4e305e2a4..e58c97cd8 100644 --- a/LoopFollowWatch/StatsView.swift +++ b/LoopFollowWatch/StatsView.swift @@ -1,18 +1,5 @@ // LoopFollow // StatsView.swift -// -// Third watch page (swipe right from Remote). Mirrors the iPhone -// LoopFollow stats block: pie chart for Low / In Range / High -// distribution, plus Avg BG, Est A1C, and Std Dev. Computed over the -// last 24 hours of the bgHistory cache that BGFetcher already holds -// for the chart (~300 readings ≈ 25h from Nightscout or Dexcom Share). -// -// Formulas match LoopFollow/Controllers/Stats.swift exactly: -// - Low/High thresholds are inclusive (<= lowLine, >= highLine) -// - Population standard deviation (divide by N, not N-1) -// - NGSP A1C: (avgBG + 46.7) / 28.7 -// IFCC A1C and per-user alt formulas live on the iPhone via -// Storage.useIFCC; we default to NGSP here to keep WatchConfig small. import Charts import SwiftUI @@ -231,9 +218,9 @@ struct StatsResult { 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) + 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 { diff --git a/LoopFollowWatch/WatchMealView.swift b/LoopFollowWatch/WatchMealView.swift index bc75fe0fc..676bac136 100644 --- a/LoopFollowWatch/WatchMealView.swift +++ b/LoopFollowWatch/WatchMealView.swift @@ -54,12 +54,12 @@ struct WatchMealView: View { } private var crownRange: ClosedRange { - guard let field = editingField else { return 0...1 } + 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 + case .carbs: return 0 ... config.maxCarbs + case .protein: return 0 ... config.maxProtein + case .fat: return 0 ... config.maxFat + case .time: return -240 ... 240 } } diff --git a/LoopFollowWatch/WatchOverrideView.swift b/LoopFollowWatch/WatchOverrideView.swift index ec9e0d0b8..1c52c295d 100644 --- a/LoopFollowWatch/WatchOverrideView.swift +++ b/LoopFollowWatch/WatchOverrideView.swift @@ -29,99 +29,99 @@ struct WatchOverrideView: View { CelebrationOverlay(isActive: $showCelebration) } } else { - ScrollView { - VStack(spacing: 6) { - if showConfirm, let override = selectedOverride { - Text(override.name) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.purple) + 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) - } + 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 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) + 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) + 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) + 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() - } + Divider() + } - Text("Available Overrides") - .font(.system(size: 14, weight: .semibold)) + 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) + 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) } - .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. diff --git a/LoopFollowWatch/WatchRemoteService.swift b/LoopFollowWatch/WatchRemoteService.swift index 14ec5d143..245d94c04 100644 --- a/LoopFollowWatch/WatchRemoteService.swift +++ b/LoopFollowWatch/WatchRemoteService.swift @@ -7,7 +7,6 @@ import UserNotifications import WatchKit class WatchRemoteService { - // MARK: - Local Notification Helper static func postLocalNotification(title: String, body: String) { @@ -299,7 +298,7 @@ class WatchRemoteService { request.setValue(payload.commandType, forHTTPHeaderField: "apns-collapse-id") request.httpBody = try? JSONEncoder().encode(message) - URLSession.shared.dataTask(with: request) { data, response, error in + URLSession.shared.dataTask(with: request) { _, response, error in DispatchQueue.main.async { if let error = error { completion(false, error.localizedDescription) diff --git a/LoopFollowWatch/WatchSessionManager.swift b/LoopFollowWatch/WatchSessionManager.swift index fea8ab1ac..8ebd3bca9 100644 --- a/LoopFollowWatch/WatchSessionManager.swift +++ b/LoopFollowWatch/WatchSessionManager.swift @@ -9,7 +9,7 @@ class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate { @Published var config: WatchConfig? - private override init() { + override private init() { super.init() // Load cached config on startup config = WatchConfig.loadFromDefaults() @@ -41,7 +41,7 @@ class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate { // MARK: - WCSessionDelegate - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error: Error?) { if let error = error { print("WCSession activation failed: \(error.localizedDescription)") return @@ -59,15 +59,15 @@ class WatchSessionManager: NSObject, ObservableObject, WCSessionDelegate { } } - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { handleReceivedConfig(applicationContext) } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { handleReceivedConfig(userInfo) } - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + func session(_: WCSession, didReceiveMessage message: [String: Any]) { handleReceivedConfig(message) } diff --git a/LoopFollowWatch/WatchTempTargetView.swift b/LoopFollowWatch/WatchTempTargetView.swift index a6ad8aafa..f9e53fe22 100644 --- a/LoopFollowWatch/WatchTempTargetView.swift +++ b/LoopFollowWatch/WatchTempTargetView.swift @@ -212,10 +212,10 @@ private struct CustomTempTargetView: View { } private var crownRange: ClosedRange { - guard !showConfirm else { return 0...1 } + guard !showConfirm else { return 0 ... 1 } switch editingField { - case .target: return 60...300 - case .duration: return 5...480 + case .target: return 60 ... 300 + case .duration: return 5 ... 480 } } diff --git a/LoopFollowWidgets/ActionShortcutWidgets.swift b/LoopFollowWidgets/ActionShortcutWidgets.swift index 9be0fcbe4..a301c4ce2 100644 --- a/LoopFollowWidgets/ActionShortcutWidgets.swift +++ b/LoopFollowWidgets/ActionShortcutWidgets.swift @@ -1,9 +1,5 @@ // LoopFollow // ActionShortcutWidgets.swift -// -// Four static circular complications for quick actions: -// Bolus (drop), Meal (fork & knife), Override (lightning bolt), Temp Target (target). -// Each deep links to the corresponding screen in the watch app. import SwiftUI import WidgetKit @@ -15,15 +11,15 @@ struct ActionEntry: TimelineEntry { } struct ActionTimelineProvider: TimelineProvider { - func placeholder(in context: Context) -> ActionEntry { + func placeholder(in _: Context) -> ActionEntry { ActionEntry(date: .now) } - func getSnapshot(in context: Context, completion: @escaping (ActionEntry) -> Void) { + func getSnapshot(in _: Context, completion: @escaping (ActionEntry) -> Void) { completion(ActionEntry(date: .now)) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { completion(Timeline(entries: [ActionEntry(date: .now)], policy: .never)) } } diff --git a/LoopFollowWidgets/BGDynamicColor.swift b/LoopFollowWidgets/BGDynamicColor.swift index 819db8818..dc9fc02ba 100644 --- a/LoopFollowWidgets/BGDynamicColor.swift +++ b/LoopFollowWidgets/BGDynamicColor.swift @@ -1,9 +1,5 @@ // LoopFollow // BGDynamicColor.swift -// -// Maps a BG value (mg/dL) to a hue-based color. -// Red (hue 0°) at ≤55, Green (hue 120°) at 100, Purple (hue 270°) at ≥220. -// Interpolates linearly through the hue spectrum between those anchors. import SwiftUI diff --git a/LoopFollowWidgets/BGLiveActivity.swift b/LoopFollowWidgets/BGLiveActivity.swift index 51a0d174b..7ae0ee824 100644 --- a/LoopFollowWidgets/BGLiveActivity.swift +++ b/LoopFollowWidgets/BGLiveActivity.swift @@ -1,111 +1,103 @@ // LoopFollow // BGLiveActivity.swift -// -// Live Activity definition for real-time BG monitoring on the iPhone Lock Screen -// and Dynamic Island. Uses the same BGComplicationContent view as the watch -// complication, so the visual style is identical. -// -// Gated behind #if os(iOS) because ActivityKit is not available on watchOS. -// When an iOS widget extension target is added, this file will compile and -// BGLiveActivityWidget can be included in the widget bundle. #if os(iOS) -import ActivityKit -import SwiftUI -import WidgetKit + import ActivityKit + import SwiftUI + import WidgetKit -// MARK: - Activity Attributes + // 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 + 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) { - self.bgValue = data.bgValue - self.direction = data.direction - self.delta = data.delta - self.bgTimestamp = data.bgTimestamp - self.iob = data.iob - self.cob = data.cob - self.basalRate = data.basalRate - self.scheduledBasal = data.scheduledBasal - self.history = data.history - self.units = data.units - self.updatedAt = data.updatedAt + 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 + // 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)) + 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 - ) + } 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)) } - } 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 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 + 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 index 9aece7917..342376b9e 100644 --- a/LoopFollowWidgets/BGTimelineProvider.swift +++ b/LoopFollowWidgets/BGTimelineProvider.swift @@ -1,30 +1,8 @@ // LoopFollow // BGTimelineProvider.swift -// -// Aggressive refresh strategy for near-real-time BG complication updates: -// -// 1. MULTI-ENTRY TIMELINE: Generate 60 entries (one per minute for 1 hour) from a -// single data snapshot. Each entry has its own `date` so WidgetKit displays -// them at the correct time — the staleness counter advances naturally without -// needing a reload. These cost zero budget; only timeline *reloads* count. -// -// 2. APP-DRIVEN RELOADS: BGFetcher calls WidgetCenter.shared.reloadAllTimelines() -// every time new BG data arrives (~every 5 min while foregrounded). Each reload -// generates a fresh batch of 12 entries. -// -// 3. BACKGROUND APP REFRESH: The watch app schedules WKApplicationRefreshBackgroundTask -// every ~15 min. When it fires, the app fetches new data from Nightscout/Dexcom -// and reloads timelines — even when the app isn't on screen. -// -// 4. TIMELINE RELOAD POLICY: .after(next) requests the system reload in 5 minutes. -// Combined with the pre-generated entries, the complication always has something -// fresh to display even if the budget is exhausted for a while. -// -// Net effect: complication updates every ~5 min in practice, with worst-case ~15 min -// from background refresh, matching or exceeding apps like SweetDreams. -import WidgetKit import SwiftUI +import WidgetKit struct BGEntry: TimelineEntry { let date: Date @@ -35,19 +13,18 @@ struct BGEntry: TimelineEntry { } struct BGTimelineProvider: TimelineProvider { - // MARK: - Required protocol - func placeholder(in context: Context) -> BGEntry { + func placeholder(in _: Context) -> BGEntry { BGEntry(date: .now, data: nil, displayDate: .now) } - func getSnapshot(in context: Context, completion: @escaping (BGEntry) -> Void) { + func getSnapshot(in _: Context, completion: @escaping (BGEntry) -> Void) { let entry = BGEntry(date: .now, data: WidgetData.load(), displayDate: .now) completion(entry) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + 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 @@ -55,9 +32,9 @@ struct BGTimelineProvider: TimelineProvider { WidgetNightscoutFetcher.fetch { result in let data: WidgetData? switch result { - case .updated(let d): data = d - case .unchanged(let d): data = d - case .failed(let d): data = d + case let .updated(d): data = d + case let .unchanged(d): data = d + case let .failed(d): data = d } let now = Date() @@ -66,7 +43,7 @@ struct BGTimelineProvider: TimelineProvider { // Each entry carries a different `displayDate` so the staleness text // advances correctly without burning a reload. var entries: [BGEntry] = [] - for i in 0..<60 { + for i in 0 ..< 60 { let entryDate = now.addingTimeInterval(Double(i) * 60) entries.append(BGEntry(date: entryDate, data: data, displayDate: entryDate)) } diff --git a/LoopFollowWidgets/CircularComplicationView.swift b/LoopFollowWidgets/CircularComplicationView.swift index a857215c1..a711614e0 100644 --- a/LoopFollowWidgets/CircularComplicationView.swift +++ b/LoopFollowWidgets/CircularComplicationView.swift @@ -1,5 +1,6 @@ // LoopFollow // CircularComplicationView.swift + // // Round complication for modular watch faces (accessoryCircular). // Layout: staleness on top, BG center, delta + trend below. diff --git a/LoopFollowWidgets/LoopFollowWidgets.swift b/LoopFollowWidgets/LoopFollowWidgets.swift index 35cb6822a..721e89af7 100644 --- a/LoopFollowWidgets/LoopFollowWidgets.swift +++ b/LoopFollowWidgets/LoopFollowWidgets.swift @@ -1,8 +1,8 @@ // LoopFollow // LoopFollowWidgets.swift -import WidgetKit import SwiftUI +import WidgetKit struct BGComplicationEntryView: View { let entry: BGEntry @@ -44,7 +44,7 @@ struct LoopFollowWidgetBundle: WidgetBundle { OverrideShortcutWidget() TempTargetShortcutWidget() #if os(iOS) - BGLiveActivityWidget() + BGLiveActivityWidget() #endif } } diff --git a/LoopFollowWidgets/RectangularComplicationView.swift b/LoopFollowWidgets/RectangularComplicationView.swift index f73d91fff..7a0c608a3 100644 --- a/LoopFollowWidgets/RectangularComplicationView.swift +++ b/LoopFollowWidgets/RectangularComplicationView.swift @@ -1,9 +1,5 @@ // LoopFollow // RectangularComplicationView.swift -// -// Reusable BG display view for both accessoryRectangular complication and -// future Live Activity usage. Text overlays left side, sparkline fades in -// from left to right with progressive line thickness. No background color. import SwiftUI import WidgetKit @@ -29,7 +25,7 @@ struct BGComplicationContent: View { stops: [ .init(color: .clear, location: 0), .init(color: .clear, location: 0.39), - .init(color: .white, location: 0.80) + .init(color: .white, location: 0.80), ], startPoint: .leading, endPoint: .trailing @@ -120,8 +116,8 @@ private struct SparklineView: 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 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 @@ -170,30 +166,30 @@ private struct SparklineView: View { 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 + 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) - ) - } + + // Stroke with progressive width + opacity + buildSingleSegment(points: screenPoints, index: i) + .stroke( + segColor.opacity(opacity), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt, lineJoin: .round) + ) + } } .widgetAccentable() } @@ -261,7 +257,7 @@ private struct SparklineView: View { if points.count == 2 { path.addLine(to: last) } else { - for i in 0..<(points.count - 1) { + 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)] diff --git a/LoopFollowWidgets/WidgetData.swift b/LoopFollowWidgets/WidgetData.swift index 5b1d481de..332262b50 100644 --- a/LoopFollowWidgets/WidgetData.swift +++ b/LoopFollowWidgets/WidgetData.swift @@ -1,26 +1,25 @@ // LoopFollow // WidgetData.swift -// Shared data model between the watch app and widget extension. import Foundation struct WidgetBGPoint: Codable, Hashable { - let value: Int // mg/dL + 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 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 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 + let units: String // "mg/dL" or "mmol/L" + let updatedAt: Date // when this snapshot was written private static let storageKey = "widgetData" @@ -38,7 +37,7 @@ struct WidgetData: Codable { } static func load() -> WidgetData? { - guard let data = sharedDefaults.data(forKey: Self.storageKey), + 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 index b2f8564b8..35fcbd33d 100644 --- a/LoopFollowWidgets/WidgetNightscoutFetcher.swift +++ b/LoopFollowWidgets/WidgetNightscoutFetcher.swift @@ -1,21 +1,14 @@ // LoopFollow // WidgetNightscoutFetcher.swift -// -// Lightweight Nightscout BG fetcher for the widget extension. Fetches only the -// 3 most recent entries (count=3) and merges any new readings into the cached -// WidgetData. This runs inside getTimeline(), which the system calls every ~5 -// minutes for an active complication — giving us a reliable update path that -// doesn't depend on the watch app's background task budget. import Foundation enum WidgetNightscoutFetcher { - /// Result of a widget-side fetch attempt. enum FetchResult { - case updated(WidgetData) // new reading(s) merged into cache + 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 + case failed(WidgetData?) // network/parse error; returns cache if available } /// Fetch the latest 3 entries from Nightscout, merge into cached WidgetData, @@ -72,7 +65,7 @@ enum WidgetNightscoutFetcher { var sgv: Double? var mbg: Double? var glucose: Double? - var date: TimeInterval // epoch millis + var date: TimeInterval // epoch millis var direction: String? var bgValue: Int? { @@ -91,7 +84,7 @@ enum WidgetNightscoutFetcher { "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "Flat": "→", "FortyFiveDown": "↘", "SingleDown": "↓", "DoubleDown": "↓↓", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", - "NONE": "-", "": "-" + "NONE": "-", "": "-", ] private static func mergeResponse(data: Data) -> FetchResult { @@ -99,7 +92,8 @@ enum WidgetNightscoutFetcher { guard let entries = try? JSONDecoder().decode([NSEntry].self, from: data), let latest = entries.first, - let latestBG = latest.bgValue else { + let latestBG = latest.bgValue + else { return .failed(cache) } From 502b31ff509eecd67dfefa96724c58acff97f471 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Fri, 8 May 2026 13:50:01 -0400 Subject: [PATCH 5/7] FPU remote from watch - missed commit - PhoneSessionManager.swift: added back in mealWithFatProtein, maxProtein, and maxFat to the buildConfig() dictionary so they're actually sent to the watch - RemoteSettingsViewModel.swift: added PhoneSessionManager.shared.sendConfig() in $mealWithFatProtein so toggling it immediately pushes the new config to the watch --- LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift | 5 ++++- LoopFollow/Watch/PhoneSessionManager.swift | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) 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/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift index 4651a5fd7..9c5eb4646 100644 --- a/LoopFollow/Watch/PhoneSessionManager.swift +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -55,6 +55,9 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { "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()), ] } From 7380183d6d8a29ea0655757f5a9f6b389286e314 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Fri, 8 May 2026 14:32:48 -0400 Subject: [PATCH 6/7] Refresh device status on bolus screen load - BGFetcher.swift: add completion handler to fetchDeviceStatus so bolus screen can await fresh IOB/COB - WatchBolusView.swift: fetch latest device status on appear with loading overlay to prevent stale COB double-counting --- LoopFollowWatch/BGFetcher.swift | 15 +- LoopFollowWatch/WatchBolusView.swift | 247 ++++++++++++++------------- 2 files changed, 139 insertions(+), 123 deletions(-) diff --git a/LoopFollowWatch/BGFetcher.swift b/LoopFollowWatch/BGFetcher.swift index 072eeb53c..e45b20133 100644 --- a/LoopFollowWatch/BGFetcher.swift +++ b/LoopFollowWatch/BGFetcher.swift @@ -324,7 +324,7 @@ class BGFetcher: ObservableObject { // MARK: - Nightscout Device Status - func fetchDeviceStatus(config: WatchConfig) { + func fetchDeviceStatus(config: WatchConfig, completion: (() -> Void)? = nil) { var components = URLComponents(string: config.nsURL) components?.path = "/api/v1/devicestatus.json" @@ -335,15 +335,20 @@ class BGFetcher: ObservableObject { queryItems.append(URLQueryItem(name: "count", value: "1")) components?.queryItems = queryItems - guard let url = components?.url else { return } + guard let url = components?.url else { + completion?() + return + } var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalCacheData - request.timeoutInterval = 15 + request.timeoutInterval = 10 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) + if let self = self, error == nil, let data = data { + self.parseDeviceStatus(data: data) + } + DispatchQueue.main.async { completion?() } }.resume() } diff --git a/LoopFollowWatch/WatchBolusView.swift b/LoopFollowWatch/WatchBolusView.swift index ba729ee89..70f390989 100644 --- a/LoopFollowWatch/WatchBolusView.swift +++ b/LoopFollowWatch/WatchBolusView.swift @@ -26,6 +26,7 @@ struct WatchBolusView: View { @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 { @@ -35,148 +36,158 @@ struct WatchBolusView: View { } var body: some View { - 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() + 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) } - CelebrationOverlay(isActive: $showCelebration) - } - } else if showConfirm { - confirmSummary - .padding(.bottom, 12) + } 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()) + CrownConfirmView(label: confirmedAmount > 0 ? "to deliver" : "to send meal") { + sendBolusAndMeal() } - .buttonStyle(.plain) - // Extra padding so the tap target doesn't bleed - // into the system back button zone on small watches. - .padding(.leading, 8) - Spacer() + } 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) - Text("Bolus") - .font(.system(size: 16, weight: .semibold)) + Spacer() - Spacer() + Text("Bolus") + .font(.system(size: 16, weight: .semibold)) - 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) + Spacer() - Text(String(format: "%.2f U", amount)) - .font(.system(size: 60, weight: .bold, design: .rounded)) - .foregroundColor(.blue) - .lineLimit(1) - .minimumScaleFactor(0.7) + 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) - HStack(spacing: 6) { - Text("Calculated: \(String(format: "%.2f", bgFetcher.recommendedBolus))U") - .font(.system(size: 16, weight: .medium)) + Text(String(format: "%.2f U", amount)) + .font(.system(size: 60, weight: .bold, design: .rounded)) .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) + + 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) } - .buttonStyle(.plain) } - } - .padding(.leading, 8) - .padding(.top, -8) + .padding(.leading, 8) + .padding(.top, -8) - Button(amount > 0 ? "Confirm" : (pendingMeal != nil ? "Skip" : "Confirm")) { - confirmedAmount = amount - showConfirm = true + Button(amount > 0 ? "Confirm" : (pendingMeal != nil ? "Skip" : "Confirm")) { + confirmedAmount = amount + showConfirm = true + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(amount <= 0 && pendingMeal == nil) } - .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) + .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) + .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") + .navigationBarBackButtonHidden(showConfirm) + .toolbar { + if showConfirm { + ToolbarItem(placement: .cancellationAction) { + Button { + showConfirm = false + } label: { + Image(systemName: "chevron.left") + } } } } - } - .onAppear { - // If launched directly (not from meal entry), clear any stale pending carbs - if pendingMeal == nil { + .onAppear { + if pendingMeal == nil { + bgFetcher.pendingCarbs = 0 + } + isRefreshing = true + bgFetcher.fetchDeviceStatus(config: config) { + bgFetcher.updateRecommendedBolus() + isRefreshing = false + } + } + .onDisappear { bgFetcher.pendingCarbs = 0 } - bgFetcher.updateRecommendedBolus() - } - .onDisappear { - bgFetcher.pendingCarbs = 0 + + if isRefreshing { + Color.black.opacity(0.6) + .ignoresSafeArea() + ProgressView() + .tint(.blue) + } } } From 0cd6e9bc3edafd8459db3c0cb1f2b281244ed23c Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Fri, 8 May 2026 19:52:06 -0400 Subject: [PATCH 7/7] Resolve a prior missed commit for missing added carbs - COB could under report by not including freshly entered COB in meal entry flow during bolus screen --- LoopFollowWatch/WatchMealView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/LoopFollowWatch/WatchMealView.swift b/LoopFollowWatch/WatchMealView.swift index 676bac136..45bce8646 100644 --- a/LoopFollowWatch/WatchMealView.swift +++ b/LoopFollowWatch/WatchMealView.swift @@ -134,9 +134,6 @@ struct WatchMealView: View { bgFetcher.updateRecommendedBolus() } } - .onDisappear { - bgFetcher.pendingCarbs = 0 - } } private let gridColumns = [