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

?YpwzU%nq_g%*?zyCTOe*7d32HR{#vWJ5>)4@(i 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΄re>kh6IpgoMJ)wd`9)Yb!go8G0M;5^ozfam0 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⋙yV_(j^KQ0-gae-*(XKm34o*SJ-F1vzu) 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

0mj4R!`a!3UDhA!qZkb%;l9U6ti^ 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 Date: Thu, 17 Sep 2026 14:01:42 +0000 Subject: [PATCH 10/12] Put dynamic BG color behind a Display setting Dynamic BG coloring was unconditional on the Watch and in the complications, which diverged from the iPhone app's threshold coloring with no way to opt out. Add Settings > General > Display > Dynamic BG Color and have all three surfaces honor it. Off (the default) colors from the iPhone's existing scheme everywhere: yellow at or above the high threshold, red at or below the low one, green between. On uses the existing hue ramp on iPhone as well as the Watch and complications. The ramp in bgDynamicColor is unchanged; the iPhone copy is byte identical to the Watch and complication copies, matching how the targets already duplicate WidgetData. The new toggle is nested under Color BG Text, which stays the master switch, so the two settings cannot contradict each other. Coloring thresholds are resolved on the phone before syncing because they depend on timeInRangeMode, which the Watch never receives; sending lowLine/highLine instead would silently mismatch the phone in the .tir and .titr modes. Complications read the synced values from the App Group, which the Watch already mirrors for Nightscout credentials. --- LoopFollow/Helpers/BGDynamicColor.swift | 31 ++++++++++++++++ LoopFollow/Settings/GeneralSettingsView.swift | 14 +++++++- LoopFollow/Storage/Storage.swift | 1 + .../ViewControllers/MainViewController.swift | 18 ++++++---- LoopFollow/Watch/PhoneSessionManager.swift | 8 +++++ LoopFollowWatch/BGChartView.swift | 4 +-- LoopFollowWatch/BGColorMode.swift | 21 +++++++++++ LoopFollowWatch/ContentView.swift | 16 ++++++--- LoopFollowWatch/WatchConfig.swift | 22 ++++++++++++ LoopFollowWidgets/BGColorMode.swift | 36 +++++++++++++++++++ .../CircularComplicationView.swift | 2 +- .../RectangularComplicationView.swift | 5 +-- 12 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 LoopFollow/Helpers/BGDynamicColor.swift create mode 100644 LoopFollowWatch/BGColorMode.swift create mode 100644 LoopFollowWidgets/BGColorMode.swift diff --git a/LoopFollow/Helpers/BGDynamicColor.swift b/LoopFollow/Helpers/BGDynamicColor.swift new file mode 100644 index 000000000..dc9fc02ba --- /dev/null +++ b/LoopFollow/Helpers/BGDynamicColor.swift @@ -0,0 +1,31 @@ +// LoopFollow +// BGDynamicColor.swift + +import SwiftUI + +/// Returns a hue-based color for a given BG value in mg/dL. +/// Low (≤55) = red, target (100) = green, high (≥220) = purple. +func bgDynamicColor(_ bg: Double) -> Color { + let low = 55.0 + let target = 100.0 + let high = 220.0 + + let redHue: CGFloat = 0.0 / 360.0 + let greenHue: CGFloat = 120.0 / 360.0 + let purpleHue: CGFloat = 270.0 / 360.0 + + let hue: CGFloat + if bg <= low { + hue = redHue + } else if bg >= high { + hue = purpleHue + } else if bg <= target { + let ratio = CGFloat((bg - low) / (target - low)) + hue = redHue + ratio * (greenHue - redHue) + } else { + let ratio = CGFloat((bg - target) / (high - target)) + hue = greenHue + ratio * (purpleHue - greenHue) + } + + return Color(hue: Double(hue), saturation: 0.6, brightness: 0.9) +} diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..31c3165b1 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -5,6 +5,7 @@ import SwiftUI struct GeneralSettingsView: View { @ObservedObject var colorBGText = Storage.shared.colorBGText + @ObservedObject var dynamicBGColor = Storage.shared.dynamicBGColor @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats @@ -46,7 +47,18 @@ struct GeneralSettingsView: View { } Toggle("Display Stats", isOn: $showStats.value) Toggle("Display Small Graph", isOn: $showSmallGraph.value) - Toggle("Color BG Text", isOn: $colorBGText.value) + Toggle("Color BG Text", isOn: $colorBGText.value.animation()) + .onChange(of: colorBGText.value) { _ in + PhoneSessionManager.shared.sendConfig() + } + + if colorBGText.value { + Toggle("Dynamic BG Color", isOn: $dynamicBGColor.value) + .onChange(of: dynamicBGColor.value) { _ in + PhoneSessionManager.shared.sendConfig() + } + } + Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) Toggle("Show Display Name", isOn: $showDisplayName.value) Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..8d24cb973 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -68,6 +68,7 @@ class Storage { // General Settings [BEGIN] var appBadge = StorageValue(key: "appBadge", defaultValue: true) var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) + var dynamicBGColor = StorageValue(key: "dynamicBGColor", defaultValue: false) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ed1ce880f..56bc0dac9 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -250,7 +250,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } .store(in: &cancellables) - Storage.shared.colorBGText.$value + Publishers.Merge(Storage.shared.colorBGText.$value, Storage.shared.dynamicBGColor.$value) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateBGTextAppearance() @@ -812,13 +812,17 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv if Storage.shared.colorBGText.value { - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - if Double(latestBG) >= thresholds.high { - Observable.shared.bgTextColor.value = .yellow - } else if Double(latestBG) <= thresholds.low { - Observable.shared.bgTextColor.value = .red + if Storage.shared.dynamicBGColor.value { + Observable.shared.bgTextColor.value = bgDynamicColor(Double(latestBG)) } else { - Observable.shared.bgTextColor.value = .green + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + if Double(latestBG) >= thresholds.high { + Observable.shared.bgTextColor.value = .yellow + } else if Double(latestBG) <= thresholds.low { + Observable.shared.bgTextColor.value = .red + } else { + Observable.shared.bgTextColor.value = .green + } } } else { Observable.shared.bgTextColor.value = .primary diff --git a/LoopFollow/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift index 9c5eb4646..a7b41753e 100644 --- a/LoopFollow/Watch/PhoneSessionManager.swift +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -28,6 +28,10 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { let lfBundleId = Bundle.main.bundleIdentifier ?? "" let lfProductionEnv = BuildDetails.default.isTestFlightBuild() + // Resolved here rather than on the watch: the phone's coloring + // thresholds depend on timeInRangeMode, which the watch never sees. + let bgColorThresholds = UnitSettingsStore.shared.effectiveThresholds() + return [ "nsURL": Storage.shared.url.value, "nsToken": Storage.shared.token.value, @@ -55,6 +59,10 @@ class PhoneSessionManager: NSObject, WCSessionDelegate { "lfTeamId": lfTeamId, "lfBundleId": lfBundleId, "lfProductionEnv": lfProductionEnv, + "colorBGText": Storage.shared.colorBGText.value, + "dynamicBGColor": Storage.shared.dynamicBGColor.value, + "bgColorLow": bgColorThresholds.low, + "bgColorHigh": bgColorThresholds.high, "mealWithFatProtein": Storage.shared.mealWithFatProtein.value, "maxProtein": Storage.shared.maxProtein.value.doubleValue(for: .gram()), "maxFat": Storage.shared.maxFat.value.doubleValue(for: .gram()), diff --git a/LoopFollowWatch/BGChartView.swift b/LoopFollowWatch/BGChartView.swift index ffdf1e126..052ca49bd 100644 --- a/LoopFollowWatch/BGChartView.swift +++ b/LoopFollowWatch/BGChartView.swift @@ -279,7 +279,7 @@ struct BGChartView: View { } private func pointColor(bgValue: Int) -> Color { - return bgDynamicColor(Double(bgValue)) + return config.bgColor(Double(bgValue)) } @ChartContentBuilder @@ -320,7 +320,7 @@ struct BGChartView: View { for i in 0 ..< (pts.count - 1) { let midBG = Double(screenPoints[i].bgValue + screenPoints[i + 1].bgValue) / 2.0 - let segColor = bgDynamicColor(midBG) + let segColor = config.bgColor(midBG) let fillPath = segmentFillPath(points: pts, index: i, bottomY: size.height) context.fill( diff --git a/LoopFollowWatch/BGColorMode.swift b/LoopFollowWatch/BGColorMode.swift new file mode 100644 index 000000000..1c732a67e --- /dev/null +++ b/LoopFollowWatch/BGColorMode.swift @@ -0,0 +1,21 @@ +// LoopFollow +// BGColorMode.swift + +import SwiftUI + +extension WatchConfig { + /// Resolves the display color for a BG value in mg/dL, mirroring what the + /// iPhone app shows for the same reading and the same settings. + /// + /// With "Dynamic BG Color" off, this is the iPhone's threshold scheme. + /// With it on, this is the continuous hue ramp in `bgDynamicColor`. + func bgColor(_ bg: Double) -> Color { + guard colorBGText else { return .primary } + guard dynamicBGColor else { + if bg >= bgColorHigh { return .yellow } + if bg <= bgColorLow { return .red } + return .green + } + return bgDynamicColor(bg) + } +} diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index f9b689904..ca0b8bc7e 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -105,7 +105,13 @@ struct ContentView: View { Date().addingTimeInterval(timeOffset.rounded() * 300) } + /// Coloring settings from the phone; defaults apply until the first sync. + private var colorConfig: WatchConfig { + sessionManager.config ?? WatchConfig(from: [:]) + } + private func bgBarGradient(bgHistory: [BGReading]) -> LinearGradient { + let colors = colorConfig let start = visibleStart let end = visibleEnd let visible = bgHistory.filter { $0.timestamp >= start && $0.timestamp <= end } @@ -117,19 +123,19 @@ struct ContentView: View { 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: [colors.bgColor(Double(reading.bgValue)).opacity(0.4)], startPoint: .leading, endPoint: .trailing) } - return LinearGradient(colors: [bgDynamicColor(100).opacity(0.4)], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [colors.bgColor(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)) + stops.append(.init(color: colors.bgColor(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)) + stops.append(.init(color: colors.bgColor(Double(lastReading.bgValue)).opacity(0.4), location: 1.0)) } return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing) } @@ -142,7 +148,7 @@ struct ContentView: View { @ViewBuilder private func mainView(reading: BGReading, config: WatchConfig) -> some View { - let bgColor = bgDynamicColor(Double(reading.bgValue)) + let bgColor = config.bgColor(Double(reading.bgValue)) let stale = isTimeTravel ? false : reading.isStale ZStack { diff --git a/LoopFollowWatch/WatchConfig.swift b/LoopFollowWatch/WatchConfig.swift index 268354785..c8c919a18 100644 --- a/LoopFollowWatch/WatchConfig.swift +++ b/LoopFollowWatch/WatchConfig.swift @@ -41,6 +41,14 @@ struct WatchConfig: Equatable { // Nightscout write auth var nsWriteAuth: Bool + // BG coloring (synced from iPhone) + var colorBGText: Bool + var dynamicBGColor: Bool + /// Already resolved on the phone from its timeInRangeMode, so the watch + /// colors from the same thresholds the phone does. + var bgColorLow: Double + var bgColorHigh: Double + // Meal settings (synced from iPhone) var mealWithFatProtein: Bool var maxProtein: Double @@ -96,6 +104,10 @@ struct WatchConfig: Equatable { "lfBundleId": lfBundleId, "lfProductionEnv": lfProductionEnv, "nsWriteAuth": nsWriteAuth, + "colorBGText": colorBGText, + "dynamicBGColor": dynamicBGColor, + "bgColorLow": bgColorLow, + "bgColorHigh": bgColorHigh, "mealWithFatProtein": mealWithFatProtein, "maxProtein": maxProtein, "maxFat": maxFat, @@ -129,6 +141,10 @@ struct WatchConfig: Equatable { lfBundleId = dict["lfBundleId"] as? String ?? "" lfProductionEnv = dict["lfProductionEnv"] as? Bool ?? false nsWriteAuth = dict["nsWriteAuth"] as? Bool ?? false + colorBGText = dict["colorBGText"] as? Bool ?? true + dynamicBGColor = dict["dynamicBGColor"] as? Bool ?? false + bgColorLow = dict["bgColorLow"] as? Double ?? 70.0 + bgColorHigh = dict["bgColorHigh"] as? Double ?? 180.0 mealWithFatProtein = dict["mealWithFatProtein"] as? Bool ?? false maxProtein = dict["maxProtein"] as? Double ?? 30.0 maxFat = dict["maxFat"] as? Double ?? 30.0 @@ -143,6 +159,12 @@ struct WatchConfig: Equatable { if let shared = UserDefaults(suiteName: WidgetData.appGroupID) { shared.set(nsURL, forKey: "nsURL") shared.set(nsToken, forKey: "nsToken") + + // Complications run in their own process and read these directly. + shared.set(colorBGText, forKey: "colorBGText") + shared.set(dynamicBGColor, forKey: "dynamicBGColor") + shared.set(bgColorLow, forKey: "bgColorLow") + shared.set(bgColorHigh, forKey: "bgColorHigh") } } diff --git a/LoopFollowWidgets/BGColorMode.swift b/LoopFollowWidgets/BGColorMode.swift new file mode 100644 index 000000000..fc925e536 --- /dev/null +++ b/LoopFollowWidgets/BGColorMode.swift @@ -0,0 +1,36 @@ +// LoopFollow +// BGColorMode.swift + +import SwiftUI + +/// BG coloring settings as mirrored into the App Group by `WatchConfig.saveToDefaults()`. +/// Complications run in their own process, so they read the values rather than +/// holding a `WatchConfig`. +struct BGColorSettings { + let colorBGText: Bool + let dynamicBGColor: Bool + let low: Double + let high: Double + + /// Defaults match the iPhone app's, so a complication rendered before the + /// first config sync shows the same colors the phone would. + static var current: BGColorSettings { + let defaults = UserDefaults(suiteName: WidgetData.appGroupID) + return BGColorSettings( + colorBGText: defaults?.object(forKey: "colorBGText") as? Bool ?? true, + dynamicBGColor: defaults?.object(forKey: "dynamicBGColor") as? Bool ?? false, + low: defaults?.object(forKey: "bgColorLow") as? Double ?? 70.0, + high: defaults?.object(forKey: "bgColorHigh") as? Double ?? 180.0 + ) + } + + func color(_ bg: Double) -> Color { + guard colorBGText else { return .primary } + guard dynamicBGColor else { + if bg >= high { return .yellow } + if bg <= low { return .red } + return .green + } + return bgDynamicColor(bg) + } +} diff --git a/LoopFollowWidgets/CircularComplicationView.swift b/LoopFollowWidgets/CircularComplicationView.swift index a711614e0..7ad72c45e 100644 --- a/LoopFollowWidgets/CircularComplicationView.swift +++ b/LoopFollowWidgets/CircularComplicationView.swift @@ -30,7 +30,7 @@ struct CircularComplicationView: View { // BG value — center, biggest Text(bgText(data)) .font(.system(size: 22, weight: .medium)) - .foregroundColor(isStale ? .secondary : bgDynamicColor(Double(data.bgValue))) + .foregroundColor(isStale ? .secondary : BGColorSettings.current.color(Double(data.bgValue))) .minimumScaleFactor(0.6) .lineLimit(1) .widgetAccentable() diff --git a/LoopFollowWidgets/RectangularComplicationView.swift b/LoopFollowWidgets/RectangularComplicationView.swift index 7a0c608a3..53b84c823 100644 --- a/LoopFollowWidgets/RectangularComplicationView.swift +++ b/LoopFollowWidgets/RectangularComplicationView.swift @@ -164,6 +164,7 @@ private struct SparklineView: View { } if screenPoints.count >= 2 { + let colorSettings = BGColorSettings.current Group { // Per-segment fill + stroke — each colored by midpoint BG value ForEach(0 ..< (screenPoints.count - 1), id: \.self) { i in @@ -171,7 +172,7 @@ private struct SparklineView: View { 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) + let segColor = colorSettings.color(midBG) // Fill slice under this segment buildSegmentFill(points: screenPoints, index: i, height: topInset + chartH) @@ -314,7 +315,7 @@ private struct StatsPanel: View { // Big BG value Text(bgText) .font(.system(size: 54, weight: .regular)) - .foregroundColor(isStale ? .secondary : bgDynamicColor(Double(data.bgValue))) + .foregroundColor(isStale ? .secondary : BGColorSettings.current.color(Double(data.bgValue))) .minimumScaleFactor(0.6) .lineLimit(1) .widgetAccentable() From 087daeda57f3aea9c2f189321cc4b1d59c956e40 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:56:19 +0000 Subject: [PATCH 11/12] Keep graph BG colors independent of Color BG Text Color BG Text governs only the current-BG number on the iPhone's main screen; the graph colors its points unconditionally in BGChartModel.colorFor. Routing the Watch's chart through that setting was wrong and turned points, the sparkline fill and the BG bar gradient white when it was off. Split the resolver in two. Charted values use bgPointColor, which is always colored and picks between the threshold scheme and the hue ramp based on Dynamic BG Color alone. The current-BG number uses bgTextColor, which falls back to .primary when Color BG Text is off. Complication BG numbers follow the main-screen number; complication sparklines follow the graph. --- LoopFollowWatch/BGChartView.swift | 4 ++-- LoopFollowWatch/BGColorMode.swift | 14 ++++++++++---- LoopFollowWatch/ContentView.swift | 10 +++++----- LoopFollowWidgets/BGColorMode.swift | 11 +++++++++-- LoopFollowWidgets/CircularComplicationView.swift | 2 +- .../RectangularComplicationView.swift | 4 ++-- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/LoopFollowWatch/BGChartView.swift b/LoopFollowWatch/BGChartView.swift index 052ca49bd..9a1782b16 100644 --- a/LoopFollowWatch/BGChartView.swift +++ b/LoopFollowWatch/BGChartView.swift @@ -279,7 +279,7 @@ struct BGChartView: View { } private func pointColor(bgValue: Int) -> Color { - return config.bgColor(Double(bgValue)) + return config.bgPointColor(Double(bgValue)) } @ChartContentBuilder @@ -320,7 +320,7 @@ struct BGChartView: View { for i in 0 ..< (pts.count - 1) { let midBG = Double(screenPoints[i].bgValue + screenPoints[i + 1].bgValue) / 2.0 - let segColor = config.bgColor(midBG) + let segColor = config.bgPointColor(midBG) let fillPath = segmentFillPath(points: pts, index: i, bottomY: size.height) context.fill( diff --git a/LoopFollowWatch/BGColorMode.swift b/LoopFollowWatch/BGColorMode.swift index 1c732a67e..6dfacd9e2 100644 --- a/LoopFollowWatch/BGColorMode.swift +++ b/LoopFollowWatch/BGColorMode.swift @@ -4,13 +4,13 @@ import SwiftUI extension WatchConfig { - /// Resolves the display color for a BG value in mg/dL, mirroring what the - /// iPhone app shows for the same reading and the same settings. + /// Color for charted BG values in mg/dL: points, sparkline fills and the + /// BG bar gradient. Always colored, matching `BGChartModel.colorFor` on the + /// iPhone, which does not consult "Color BG Text" either. /// /// With "Dynamic BG Color" off, this is the iPhone's threshold scheme. /// With it on, this is the continuous hue ramp in `bgDynamicColor`. - func bgColor(_ bg: Double) -> Color { - guard colorBGText else { return .primary } + func bgPointColor(_ bg: Double) -> Color { guard dynamicBGColor else { if bg >= bgColorHigh { return .yellow } if bg <= bgColorLow { return .red } @@ -18,4 +18,10 @@ extension WatchConfig { } return bgDynamicColor(bg) } + + /// Color for the current-BG number, which honors "Color BG Text" the way + /// the iPhone's main screen does. + func bgTextColor(_ bg: Double) -> Color { + colorBGText ? bgPointColor(bg) : .primary + } } diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index ca0b8bc7e..8a34d7984 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -123,19 +123,19 @@ struct ContentView: View { else { // Fall back to single color from current reading if let reading = visible.first ?? bgHistory.last { - return LinearGradient(colors: [colors.bgColor(Double(reading.bgValue)).opacity(0.4)], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [colors.bgPointColor(Double(reading.bgValue)).opacity(0.4)], startPoint: .leading, endPoint: .trailing) } - return LinearGradient(colors: [colors.bgColor(100).opacity(0.4)], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [colors.bgPointColor(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: colors.bgColor(Double(visible[i].bgValue)).opacity(0.4), location: t)) + stops.append(.init(color: colors.bgPointColor(Double(visible[i].bgValue)).opacity(0.4), location: t)) } if let lastReading = visible.last { - stops.append(.init(color: colors.bgColor(Double(lastReading.bgValue)).opacity(0.4), location: 1.0)) + stops.append(.init(color: colors.bgPointColor(Double(lastReading.bgValue)).opacity(0.4), location: 1.0)) } return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing) } @@ -148,7 +148,7 @@ struct ContentView: View { @ViewBuilder private func mainView(reading: BGReading, config: WatchConfig) -> some View { - let bgColor = config.bgColor(Double(reading.bgValue)) + let bgColor = config.bgTextColor(Double(reading.bgValue)) let stale = isTimeTravel ? false : reading.isStale ZStack { diff --git a/LoopFollowWidgets/BGColorMode.swift b/LoopFollowWidgets/BGColorMode.swift index fc925e536..7a8edcc0f 100644 --- a/LoopFollowWidgets/BGColorMode.swift +++ b/LoopFollowWidgets/BGColorMode.swift @@ -24,8 +24,9 @@ struct BGColorSettings { ) } - func color(_ bg: Double) -> Color { - guard colorBGText else { return .primary } + /// Color for charted BG values: always colored, matching the iPhone's + /// graph, which does not consult "Color BG Text". + func pointColor(_ bg: Double) -> Color { guard dynamicBGColor else { if bg >= high { return .yellow } if bg <= low { return .red } @@ -33,4 +34,10 @@ struct BGColorSettings { } return bgDynamicColor(bg) } + + /// Color for the current-BG number, which honors "Color BG Text" the way + /// the iPhone's main screen does. + func textColor(_ bg: Double) -> Color { + colorBGText ? pointColor(bg) : .primary + } } diff --git a/LoopFollowWidgets/CircularComplicationView.swift b/LoopFollowWidgets/CircularComplicationView.swift index 7ad72c45e..5726e92e8 100644 --- a/LoopFollowWidgets/CircularComplicationView.swift +++ b/LoopFollowWidgets/CircularComplicationView.swift @@ -30,7 +30,7 @@ struct CircularComplicationView: View { // BG value — center, biggest Text(bgText(data)) .font(.system(size: 22, weight: .medium)) - .foregroundColor(isStale ? .secondary : BGColorSettings.current.color(Double(data.bgValue))) + .foregroundColor(isStale ? .secondary : BGColorSettings.current.textColor(Double(data.bgValue))) .minimumScaleFactor(0.6) .lineLimit(1) .widgetAccentable() diff --git a/LoopFollowWidgets/RectangularComplicationView.swift b/LoopFollowWidgets/RectangularComplicationView.swift index 53b84c823..5ab7eec9b 100644 --- a/LoopFollowWidgets/RectangularComplicationView.swift +++ b/LoopFollowWidgets/RectangularComplicationView.swift @@ -172,7 +172,7 @@ private struct SparklineView: View { 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 = colorSettings.color(midBG) + let segColor = colorSettings.pointColor(midBG) // Fill slice under this segment buildSegmentFill(points: screenPoints, index: i, height: topInset + chartH) @@ -315,7 +315,7 @@ private struct StatsPanel: View { // Big BG value Text(bgText) .font(.system(size: 54, weight: .regular)) - .foregroundColor(isStale ? .secondary : BGColorSettings.current.color(Double(data.bgValue))) + .foregroundColor(isStale ? .secondary : BGColorSettings.current.textColor(Double(data.bgValue))) .minimumScaleFactor(0.6) .lineLimit(1) .widgetAccentable() From 2591c657744c4138cea032654a39bcce5ed6698b Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:07:12 +0000 Subject: [PATCH 12/12] Apply dynamic BG color to the iPhone graph The setting reached the BG number and the Watch but not the iPhone's own chart, which still colored every point from the thresholds. Branch colorFor on the setting so points and line runs use the hue ramp when it is on, and mark the chart dirty when it is toggled so the graph rebuilds on return to the main screen, the same way the time zone toggles do. Color BG Text is deliberately not consulted here: it governs the BG number only, so the graph stays colored either way. --- LoopFollow/Charts/BGChartModel.swift | 3 +++ LoopFollow/Settings/GeneralSettingsView.swift | 1 + 2 files changed, 4 insertions(+) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 9b451ac6a..3f40b9dda 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -257,6 +257,9 @@ final class BGChartModel: ObservableObject { } private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color { + if Storage.shared.dynamicBGColor.value { + return bgDynamicColor(Double(sgv)) + } if Double(sgv) >= thresholds.high { return .yellow } else if Double(sgv) <= thresholds.low { diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 31c3165b1..c05147586 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -56,6 +56,7 @@ struct GeneralSettingsView: View { Toggle("Dynamic BG Color", isOn: $dynamicBGColor.value) .onChange(of: dynamicBGColor.value) { _ in PhoneSessionManager.shared.sendConfig() + markChartSettingsDirty() } }
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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 = [ From e0a63bcf0c1caeacb2acb7524cf7c9b3db11a775 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:04:55 -0400 Subject: [PATCH 08/12] Port Apple Watch support to current dev --- LoopFollow.xcodeproj/project.pbxproj | 302 ++++ .../xcschemes/LoopFollowWatch.xcscheme | 77 + LoopFollow/Application/AppDelegate.swift | 2 + .../Controllers/Nightscout/BGData.swift | 1 + .../NightscoutSettingsViewModel.swift | 2 + .../Settings/RemoteSettingsViewModel.swift | 5 +- .../Settings/DexcomSettingsViewModel.swift | 3 + .../Settings/UnitsConfigurationView.swift | 3 + LoopFollow/Watch/PhoneSessionManager.swift | 127 ++ .../AppIcon.appiconset/1024.png | Bin 0 -> 56954 bytes .../AppIcon.appiconset/Contents.json | 14 + LoopFollowWatch/Assets.xcassets/Contents.json | 6 + LoopFollowWatch/BGChartView.swift | 395 +++++ LoopFollowWatch/BGDynamicColor.swift | 31 + LoopFollowWatch/BGFetcher.swift | 1494 +++++++++++++++++ LoopFollowWatch/BGReading.swift | 67 + LoopFollowWatch/CelebrationOverlay.swift | 331 ++++ LoopFollowWatch/ContentView.swift | 380 +++++ LoopFollowWatch/CrownCaptureView.swift | 34 + LoopFollowWatch/CrownConfirmView.swift | 121 ++ LoopFollowWatch/CrownRotationModifier.swift | 38 + LoopFollowWatch/FollowStatusView.swift | 457 +++++ LoopFollowWatch/Info.plist | 32 + LoopFollowWatch/LoopFollowWatch.entitlements | 10 + LoopFollowWatch/LoopFollowWatchApp.swift | 141 ++ LoopFollowWatch/LoopStatus.swift | 41 + LoopFollowWatch/NavigationRouter.swift | 45 + LoopFollowWatch/OverridePreset.swift | 12 + LoopFollowWatch/RemoteControlView.swift | 113 ++ LoopFollowWatch/StatsView.swift | 280 +++ LoopFollowWatch/Treatment.swift | 32 + LoopFollowWatch/WatchBolusView.swift | 393 +++++ LoopFollowWatch/WatchConfig.swift | 155 ++ LoopFollowWatch/WatchMealView.swift | 265 +++ LoopFollowWatch/WatchOverrideView.swift | 173 ++ LoopFollowWatch/WatchRemoteService.swift | 481 ++++++ LoopFollowWatch/WatchSessionManager.swift | 86 + LoopFollowWatch/WatchTempTargetView.swift | 353 ++++ LoopFollowWidgets/ActionShortcutWidgets.swift | 110 ++ LoopFollowWidgets/BGDynamicColor.swift | 31 + LoopFollowWidgets/BGLiveActivity.swift | 103 ++ LoopFollowWidgets/BGTimelineProvider.swift | 57 + .../CircularComplicationView.swift | 91 + LoopFollowWidgets/Info.plist | 29 + .../LoopFollowWidgets.entitlements | 10 + LoopFollowWidgets/LoopFollowWidgets.swift | 50 + .../RectangularComplicationView.swift | 374 +++++ LoopFollowWidgets/WidgetData.swift | 45 + .../WidgetNightscoutFetcher.swift | 158 ++ 49 files changed, 7559 insertions(+), 1 deletion(-) 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/BGDynamicColor.swift create mode 100644 LoopFollowWatch/BGFetcher.swift create mode 100644 LoopFollowWatch/BGReading.swift create mode 100644 LoopFollowWatch/CelebrationOverlay.swift create mode 100644 LoopFollowWatch/ContentView.swift create mode 100644 LoopFollowWatch/CrownCaptureView.swift create mode 100644 LoopFollowWatch/CrownConfirmView.swift create mode 100644 LoopFollowWatch/CrownRotationModifier.swift create mode 100644 LoopFollowWatch/FollowStatusView.swift create mode 100644 LoopFollowWatch/Info.plist create mode 100644 LoopFollowWatch/LoopFollowWatch.entitlements create mode 100644 LoopFollowWatch/LoopFollowWatchApp.swift create mode 100644 LoopFollowWatch/LoopStatus.swift create mode 100644 LoopFollowWatch/NavigationRouter.swift create mode 100644 LoopFollowWatch/OverridePreset.swift create mode 100644 LoopFollowWatch/RemoteControlView.swift create mode 100644 LoopFollowWatch/StatsView.swift create mode 100644 LoopFollowWatch/Treatment.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 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 560cd2f8e..6e53652a2 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 37A4BDDB2F5B6B4A00EEB289 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */; }; 37A4BDDD2F5B6B4A00EEB289 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */; }; 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BB0100000000000B000000AA /* LoopFollowWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = BB01000000000001000000AA /* LoopFollowWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + CC01000000000001000000AA /* LoopFollowWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CC01000000000002000000AA /* LoopFollowWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; DD50C10A2F60A00000000001 /* SocketIO in Frameworks */ = {isa = PBXBuildFile; productRef = DD50C10A2F60A00000000003 /* SocketIO */; }; DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; }; @@ -27,6 +29,20 @@ remoteGlobalIDString = 37A4BDD82F5B6B4A00EEB289; remoteInfo = LoopFollowLAExtensionExtension; }; + BB0100000000000D000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = BB01000000000003000000AA; + remoteInfo = LoopFollowWatch; + }; + CC01000000000010000000AA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = CC01000000000003000000AA; + remoteInfo = LoopFollowWidgets; + }; DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = FC97880C2485969B00A7906C /* Project object */; @@ -48,6 +64,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + BB0100000000000A000000AA /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + BB0100000000000B000000AA /* LoopFollowWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000020000000AA /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + CC01000000000001000000AA /* LoopFollowWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -57,6 +95,8 @@ 37A4BDDA2F5B6B4A00EEB289 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = /System/Library/Frameworks/WidgetKit.framework; sourceTree = ""; }; 37A4BDDC2F5B6B4A00EEB289 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = /System/Library/Frameworks/SwiftUI.framework; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BB01000000000001000000AA /* LoopFollowWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoopFollowWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CC01000000000002000000AA /* LoopFollowWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = ""; }; DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; @@ -71,6 +111,20 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BB01000000000011000000AA /* Exceptions for "LoopFollowWatch" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = BB01000000000003000000AA /* LoopFollowWatch */; + }; + CC01000000000011000000AA /* Exceptions for "LoopFollowWidgets" folder in "LoopFollowWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = CC01000000000003000000AA /* LoopFollowWidgets */; + }; D736FE5E8494B2581A4EA95F /* Exceptions for "LoopFollow" folder in "LoopFollow" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -107,6 +161,30 @@ path = Shared; sourceTree = ""; }; + BB01000000000002000000AA /* LoopFollowWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BB01000000000011000000AA /* Exceptions for "LoopFollowWatch" folder in "LoopFollowWatch" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = LoopFollowWatch; + sourceTree = ""; + }; + CC01000000000004000000AA /* LoopFollowWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CC01000000000011000000AA /* Exceptions for "LoopFollowWidgets" folder in "LoopFollowWidgets" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = LoopFollowWidgets; + sourceTree = ""; + }; DDCC3AD72DDE1790006F1C10 /* Tests */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -130,6 +208,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BB01000000000006000000AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CC01000000000006000000AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDCC3AD32DDE1790006F1C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -191,6 +283,8 @@ FC5A5C3C2497B229009C550E /* Config.xcconfig */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + BB01000000000002000000AA /* LoopFollowWatch */, + CC01000000000004000000AA /* LoopFollowWidgets */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -205,6 +299,8 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + BB01000000000001000000AA /* LoopFollowWatch.app */, + CC01000000000002000000AA /* LoopFollowWidgets.appex */, ); name = Products; sourceTree = ""; @@ -233,6 +329,48 @@ 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; + 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; + 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" */; @@ -266,11 +404,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 = ( 9555FE45F7FAEFA6142D04CA /* LoopFollow */, @@ -297,6 +437,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + CC01000000000003000000AA = { + CreatedOnToolsVersion = 16.3; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -325,6 +468,8 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + BB01000000000003000000AA /* LoopFollowWatch */, + CC01000000000003000000AA /* LoopFollowWidgets */, ); }; /* End PBXProject section */ @@ -337,6 +482,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; @@ -448,6 +607,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; @@ -471,6 +644,17 @@ target = 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */; targetProxy = 37A4BDE62F5B6B4C00EEB289 /* PBXContainerItemProxy */; }; + BB0100000000000C000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = BB01000000000003000000AA /* LoopFollowWatch */; + targetProxy = BB0100000000000D000000AA /* PBXContainerItemProxy */; + }; + CC01000000000012000000AA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CC01000000000003000000AA /* LoopFollowWidgets */; + targetProxy = CC01000000000010000000AA /* PBXContainerItemProxy */; + }; DDCC3ADB2DDE1790006F1C10 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FC9788132485969B00A7906C /* LoopFollow */; @@ -582,6 +766,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 = "$(LF_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 = "$(LF_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 = "$(LF_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 = "$(LF_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 = { @@ -825,6 +1109,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BB01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB01000000000007000000AA /* Debug */, + BB01000000000008000000AA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CC01000000000009000000AA /* Build configuration list for PBXNativeTarget "LoopFollowWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC01000000000007000000BB /* Debug */, + CC01000000000008000000BB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDCC3ADC2DDE1790006F1C10 /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme new file mode 100644 index 000000000..fae7819ae --- /dev/null +++ b/LoopFollow.xcworkspace/xcshareddata/xcschemes/LoopFollowWatch.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3c364fc2c..9e90215b4 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -87,6 +87,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } + PhoneSessionManager.shared.startSession() + return true } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index a568746c7..f05855b38 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -339,6 +339,7 @@ extension MainViewController { ) } Storage.shared.lastBGChecked.value = Date() + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift index 1065cc892..dd1b241b6 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsViewModel.swift @@ -20,6 +20,7 @@ class NightscoutSettingsViewModel: ObservableObject { if newValue != nightscoutURL { Storage.shared.url.value = newValue triggerCheckStatus() + PhoneSessionManager.shared.sendConfig() } } } @@ -29,6 +30,7 @@ class NightscoutSettingsViewModel: ObservableObject { if newValue != nightscoutToken { Storage.shared.token.value = NightscoutUtils.sanitizeConnectionInput(newValue) triggerCheckStatus() + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 2b3ab9af0..c711fcbc3 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -166,7 +166,10 @@ class RemoteSettingsViewModel: ObservableObject { $mealWithFatProtein .dropFirst() - .sink { [weak self] in self?.storage.mealWithFatProtein.value = $0 } + .sink { [weak self] in + self?.storage.mealWithFatProtein.value = $0 + PhoneSessionManager.shared.sendConfig() + } .store(in: &cancellables) // Device type monitoring diff --git a/LoopFollow/Settings/DexcomSettingsViewModel.swift b/LoopFollow/Settings/DexcomSettingsViewModel.swift index 5a929cbe6..2b609b381 100644 --- a/LoopFollow/Settings/DexcomSettingsViewModel.swift +++ b/LoopFollow/Settings/DexcomSettingsViewModel.swift @@ -21,6 +21,7 @@ class DexcomSettingsViewModel: ObservableObject { if newValue != userName { Storage.shared.shareUserName.value = newValue scheduleVerification() + PhoneSessionManager.shared.sendConfig() } } } @@ -30,6 +31,7 @@ class DexcomSettingsViewModel: ObservableObject { if newValue != password { Storage.shared.sharePassword.value = newValue scheduleVerification() + PhoneSessionManager.shared.sendConfig() } } } @@ -39,6 +41,7 @@ class DexcomSettingsViewModel: ObservableObject { if newValue != server { Storage.shared.shareServer.value = newValue scheduleVerification() + PhoneSessionManager.shared.sendConfig() } } } diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 2124077c7..60d659722 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -60,6 +60,7 @@ struct UnitsConfigurationView: View { .pickerStyle(.segmented) .onChange(of: glucoseUnit) { newValue in UnitSettingsStore.shared.glucoseUnit = newValue + PhoneSessionManager.shared.sendConfig() } } } @@ -89,6 +90,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", @@ -99,6 +101,7 @@ struct UnitsConfigurationView: View { .onChange(of: highValue) { newValue in Storage.shared.highLine.value = newValue Observable.shared.chartSettingsChanged.value = true + PhoneSessionManager.shared.sendConfig() } } } header: { diff --git a/LoopFollow/Watch/PhoneSessionManager.swift b/LoopFollow/Watch/PhoneSessionManager.swift new file mode 100644 index 000000000..9c5eb4646 --- /dev/null +++ b/LoopFollow/Watch/PhoneSessionManager.swift @@ -0,0 +1,127 @@ +// LoopFollow +// PhoneSessionManager.swift + +import Foundation +import HealthKit +import WatchConnectivity + +class PhoneSessionManager: NSObject, WCSessionDelegate { + static let shared = PhoneSessionManager() + + override private init() { + super.init() + } + + func startSession() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + private func buildConfig() -> [String: Any] { + // LF (LoopFollow's own) APNS credentials — used by the watch to + // populate `return_notification` so Trio acks land here on the + // iPhone (which can then forward to the watch via WCSession). + // Mirrors PushNotificationManager.createReturnNotificationInfo(). + let lfDeviceToken = Observable.shared.loopFollowDeviceToken.value + let lfTeamId = BuildDetails.default.teamID ?? "" + let lfBundleId = Bundle.main.bundleIdentifier ?? "" + let lfProductionEnv = BuildDetails.default.isTestFlightBuild() + + return [ + "nsURL": Storage.shared.url.value, + "nsToken": Storage.shared.token.value, + "dexUsername": Storage.shared.shareUserName.value, + "dexPassword": Storage.shared.sharePassword.value, + "dexServer": Storage.shared.shareServer.value, + "units": Storage.shared.units.value, + "lowLine": Storage.shared.lowLine.value, + "highLine": Storage.shared.highLine.value, + "remoteType": Storage.shared.remoteType.value.rawValue, + "maxBolus": Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()), + "maxCarbs": Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + "trcDeviceToken": Storage.shared.deviceToken.value, + "trcSharedSecret": Storage.shared.sharedSecret.value, + "trcApnsKey": Storage.shared.remoteApnsKey.value, + "trcKeyId": Storage.shared.remoteKeyId.value, + "trcTeamId": Storage.shared.teamId.value ?? "", + "trcBundleId": Storage.shared.bundleId.value, + "trcProductionEnv": Storage.shared.productionEnvironment.value, + "trcUser": Storage.shared.user.value, + "nsWriteAuth": Storage.shared.nsWriteAuth.value, + "lfDeviceToken": lfDeviceToken, + "lfApnsKey": Storage.shared.lfApnsKey.value, + "lfKeyId": Storage.shared.lfKeyId.value, + "lfTeamId": lfTeamId, + "lfBundleId": lfBundleId, + "lfProductionEnv": lfProductionEnv, + "mealWithFatProtein": Storage.shared.mealWithFatProtein.value, + "maxProtein": Storage.shared.maxProtein.value.doubleValue(for: .gram()), + "maxFat": Storage.shared.maxFat.value.doubleValue(for: .gram()), + ] + } + + func sendConfig() { + guard WCSession.default.activationState == .activated else { return } + let config = buildConfig() + try? WCSession.default.updateApplicationContext(config) + + // Also send via message for immediate delivery if Watch is reachable + if WCSession.default.isReachable { + WCSession.default.sendMessage(config, replyHandler: nil, errorHandler: nil) + } + } + + // MARK: - WCSessionDelegate + + func session(_: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error _: Error?) { + if activationState == .activated { + sendConfig() + } + } + + func sessionDidBecomeInactive(_: WCSession) {} + + func sessionDidDeactivate(_: WCSession) { + WCSession.default.activate() + } + + // Re-send config when Watch becomes reachable (handles fresh install) + func sessionReachabilityDidChange(_ session: WCSession) { + if session.isReachable { + sendConfig() + } + } + + // Handle Watch requesting config via applicationContext + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + if applicationContext["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via sendMessage (with reply) + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + if message["requestConfig"] != nil { + let config = buildConfig() + replyHandler(config) + // Also update application context so it's cached + try? WCSession.default.updateApplicationContext(config) + } else { + replyHandler([:]) + } + } + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + if message["requestConfig"] != nil { + sendConfig() + } + } + + // Handle Watch requesting config via transferUserInfo + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if userInfo["requestConfig"] != nil { + sendConfig() + } + } +} diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..8c1c15231e3b35288c91bbdbf211af3a91f57a90 GIT binary patch literal 56954 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⋙yV_(j^KQ0-gae-*(XKm34o*SJ-F1vzu) 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