Преглед изворни кода

Merge branch 'dev' into feat/dev-medtrum

marionbarker пре 8 месеци
родитељ
комит
aa34d29763
28 измењених фајлова са 355 додато и 55 уклоњено
  1. 1 1
      Config.xcconfig
  2. 1 1
      DanaKit
  3. 34 14
      Trio.xcodeproj/project.pbxproj
  4. 5 0
      Trio/Sources/APS/APSManager.swift
  5. 1 0
      Trio/Sources/Application/TrioApp.swift
  6. 1 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  7. 6 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  8. 13 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  9. 1 1
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  10. 7 0
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  11. 4 0
      Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift
  12. 31 0
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutUploadGlucoseStepView.swift
  13. 45 0
      Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutUploadStepView.swift
  14. 2 0
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  15. 15 0
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift
  16. 2 1
      Trio/Sources/Services/Calendar/CalendarManager.swift
  17. 13 1
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  18. 115 0
      Trio/Sources/Services/IOB/IOBService.swift
  19. 1 2
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  20. 0 1
      Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift
  21. 2 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  22. 10 0
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  23. 3 25
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift
  24. 1 0
      Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift
  25. 15 3
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  26. 24 3
      Trio/Sources/Services/WatchManager/GarminManager.swift
  27. 1 0
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  28. 1 1
      Trio/Sources/Shortcuts/State/StateIntentRequest.swift

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.5.1
-APP_DEV_VERSION = 0.5.1.20
+APP_DEV_VERSION = 0.5.1.24
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit bd52ae898a59a05421b0f860e472b1d5aeae7cdc
+Subproject commit 33a8d4705fc82b371daf4bd5977ed2cfaf420204

+ 34 - 14
Trio.xcodeproj/project.pbxproj

@@ -256,6 +256,7 @@
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
+		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
@@ -447,7 +448,9 @@
 		BDFF7A8B2D25F97D0016C40C /* Unit Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF7A8A2D25F97D0016C40C /* Unit Tests.swift */; };
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
 		C21FE1E72DA59C6B007D550B /* GlucoseDailyDistributionChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21FE1E62DA59C6B007D550B /* GlucoseDailyDistributionChart.swift */; };
+		C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C263D59E2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift */; };
 		C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28DD7252DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift */; };
+		C29835B02E2AA3F30068C5BB /* NightscoutUploadStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29835AF2E2AA3F30068C5BB /* NightscoutUploadStepView.swift */; };
 		C29E268A2DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = C29E26892DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift */; };
 		C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */; };
 		C2A6D1E42DB1581D0036DB66 /* GlucoseStatsSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A6D1E32DB1581D0036DB66 /* GlucoseStatsSetup.swift */; };
@@ -1077,6 +1080,7 @@
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
+		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
@@ -1269,7 +1273,9 @@
 		BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorRootView.swift; sourceTree = "<group>"; };
 		C19984D62EFC0035A9E9644D /* TreatmentsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsProvider.swift; sourceTree = "<group>"; };
 		C21FE1E62DA59C6B007D550B /* GlucoseDailyDistributionChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDailyDistributionChart.swift; sourceTree = "<group>"; };
+		C263D59E2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadGlucoseStepView.swift; sourceTree = "<group>"; };
 		C28DD7252DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucosePercentileDetailView.swift; sourceTree = "<group>"; };
+		C29835AF2E2AA3F30068C5BB /* NightscoutUploadStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadStepView.swift; sourceTree = "<group>"; };
 		C29E26892DADFD2A00F87E75 /* GlucoseDailyPercentileChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDailyPercentileChart.swift; sourceTree = "<group>"; };
 		C2A0A42E2CE0312C003B98E8 /* ConstantValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantValues.swift; sourceTree = "<group>"; };
 		C2A6D1E32DB1581D0036DB66 /* GlucoseStatsSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStatsSetup.swift; sourceTree = "<group>"; };
@@ -2023,17 +2029,18 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
-				BD47FD112D88AA630043966B /* OnboardingManager */,
-				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
-				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3811DE9225C9D88200A708ED /* Appearance */,
+				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
+				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
+				3BF85FE22E427312000D7351 /* IOB */,
 				6B1A8D2C2B156EC100E76752 /* LiveActivity */,
 				3811DE9425C9D88200A708ED /* Network */,
 				38B4F3C425E5016800E76A18 /* Notifications */,
+				BD47FD112D88AA630043966B /* OnboardingManager */,
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
@@ -2613,6 +2620,14 @@
 			path = JSONImporterData;
 			sourceTree = "<group>";
 		};
+		3BF85FE22E427312000D7351 /* IOB */ = {
+			isa = PBXGroup;
+			children = (
+				3BF85FE12E427312000D7351 /* IOBService.swift */,
+			);
+			path = IOB;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -2665,22 +2680,22 @@
 			isa = PBXGroup;
 			children = (
 				49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */,
-				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
-				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
-				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
 				5837A52F2BD2E3C700A5DC04 /* CarbEntryStored+helper.swift */,
-				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
-				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
-				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
-				581AC4382BE22ED10038760C /* JSONConverter.swift */,
-				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
+				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
 				582FAE422C05102C00D1C13F /* CoreDataError.swift */,
 				BDF34EBD2C0A31D000D51995 /* CustomNotification.swift */,
-				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
+				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
+				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
+				581AC4382BE22ED10038760C /* JSONConverter.swift */,
+				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
+				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
 				BD793CAF2CE7C60E00D669AC /* OverrideRunStored+helper.swift */,
-				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
-				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
 				BD793CB12CE8032E00D669AC /* TempTargetRunStored.swift */,
+				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -3342,7 +3357,9 @@
 		DD3F1F8E2D9E151200DCE7B3 /* Nightscout */ = {
 			isa = PBXGroup;
 			children = (
+				C263D59E2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift */,
 				DD3F1F8F2D9E153A00DCE7B3 /* NightscoutImportStepView.swift */,
+				C29835AF2E2AA3F30068C5BB /* NightscoutUploadStepView.swift */,
 				DD3F1F8C2D9E0E0000DCE7B3 /* NightscoutSetupStepView.swift */,
 				DD3F1F8A2D9E08B200DCE7B3 /* NightscoutLoginStepView.swift */,
 			);
@@ -4197,6 +4214,7 @@
 				58645BA32CA2D325008AFCE7 /* BatterySetup.swift in Sources */,
 				DD82D4B82DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift in Sources */,
 				388E5A5C25B6F0770019842D /* JSON.swift in Sources */,
+				C263D59F2E4267F400CBF08C /* NightscoutUploadGlucoseStepView.swift in Sources */,
 				3811DF0225CA9FEA00A708ED /* Credentials.swift in Sources */,
 				5837A5302BD2E3C700A5DC04 /* CarbEntryStored+helper.swift in Sources */,
 				389A572026079BAA00BC102F /* Interpolation.swift in Sources */,
@@ -4328,6 +4346,7 @@
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
+				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,
@@ -4492,6 +4511,7 @@
 				CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */,
 				110AEDED2C51A0AE00615CC9 /* ShortcutsConfigProvider.swift in Sources */,
 				38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */,
+				C29835B02E2AA3F30068C5BB /* NightscoutUploadStepView.swift in Sources */,
 				58D08B3A2C8DFECD00AA37D3 /* TempTargets.swift in Sources */,
 				38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */,
 				CE7CA3542A064973004BE681 /* TempPresetsIntentRequest.swift in Sources */,

+ 5 - 0
Trio/Sources/APS/APSManager.swift

@@ -30,6 +30,7 @@ protocol APSManager {
     func roundBolus(amount: Decimal) -> Decimal
     var lastError: CurrentValueSubject<Error?, Never> { get }
     func cancelBolus(_ callback: ((Bool, String) -> Void)?) async
+    var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
 enum APSError: LocalizedError {
@@ -107,6 +108,7 @@ final class BaseAPSManager: APSManager, Injectable {
     let isLooping = CurrentValueSubject<Bool, Never>(false)
     let lastLoopDateSubject = PassthroughSubject<Date, Never>()
     let lastError = CurrentValueSubject<Error?, Never>(nil)
+    let iobFileDidUpdate = PassthroughSubject<Void, Never>()
 
     let bolusProgress = CurrentValueSubject<Decimal?, Never>(nil)
 
@@ -459,6 +461,7 @@ final class BaseAPSManager: APSManager, Injectable {
             _ = try await autosenseResult
             try await openAPS.createProfiles()
             let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
                 throw APSError.glucoseError(message: "Glucose validation failed")
@@ -474,6 +477,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
+            iobFileDidUpdate.send(())
+
             // if we have a glucose validation error we might still run
             // determineBasal to try to get IoB and CoB updates but we
             // know that it will fail, so the invalidGlucoseError always

+ 1 - 0
Trio/Sources/Application/TrioApp.swift

@@ -84,6 +84,7 @@ extension Notification.Name {
         if #available(iOS 16.2, *) {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
+        _ = resolver.resolve(IOBService.self)!
     }
 
     init() {

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -28,5 +28,6 @@ final class ServiceAssembly: Assembly {
                 LiveActivityManager(resolver: r)
             }
         }
+        container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
     }
 }

+ 6 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -162481,6 +162481,12 @@
         }
       }
     },
+    "Please choose if you want to upload CGM readings from Trio to Nightscout." : {
+
+    },
+    "Please choose if you want to upload treatment data to Nightscout." : {
+
+    },
     "Please enter your credentials:" : {
       "localizations" : {
         "bg" : {

+ 13 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -20,6 +20,7 @@ extension Home {
         @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
+        @ObservationIgnored @Injected() var iobService: IOBService!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
@@ -65,6 +66,7 @@ extension Home {
         var manualTempBasal = false
         var isSmoothingEnabled = false
         var maxIOB: Decimal = 0.0
+        var currentIOB: Decimal = 0.0
         var autosensMax: Decimal = 1.2
         var lowGlucose: Decimal = 70
         var highGlucose: Decimal = 180
@@ -215,12 +217,23 @@ extension Home {
                     group.addTask {
                         self.setupTempTargetsRunStored()
                     }
+                    group.addTask {
+                        self.iobService.updateIOB()
+                    }
                 }
             }
         }
 
         // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
         private func registerSubscribers() {
+            iobService.iobPublisher
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] _ in
+                    guard let self = self else { return }
+                    self.currentIOB = self.iobService.currentIOB ?? 0
+                }
+                .store(in: &subscriptions)
+
             glucoseStorage.updatePublisher
                 .receive(on: queue)
                 .sink { [weak self] _ in

+ 1 - 1
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -432,7 +432,7 @@ extension Home {
                     Text(
                         (
                             Formatter.decimalFormatterWithTwoFractionDigits
-                                .string(from: (state.enactedAndNonEnactedDeterminations.first?.iob ?? 0) as NSNumber) ?? "0"
+                                .string(from: state.currentIOB as NSNumber) ?? "0"
                         ) +
                             String(localized: " U", comment: "Insulin unit")
                     )

+ 7 - 0
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -101,6 +101,8 @@ extension Onboarding {
         var isConnectedToNS: Bool = false
         var nightscoutImportError: NightscoutImportError?
         var nightscoutImportStatus: ImportStatus = .none
+        var isUploadEnabled: Bool = true
+        var uploadGlucose: Bool = true
 
         // MARK: - Units and Pump Omboarding Option
 
@@ -705,6 +707,11 @@ extension Onboarding {
             var settingsCopy = settingsManager.settings
             settingsCopy.units = units
 
+            if nightscoutSetupOption == .setupNightscout {
+                settingsCopy.isUploadEnabled = isUploadEnabled
+                settingsCopy.uploadGlucose = uploadGlucose
+            }
+
             // ensure existing values cannot exceed new guardrails
             if !isFreshTrioInstall {
                 let providedSettings = settingsProvider.settings

+ 4 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift

@@ -333,6 +333,10 @@ struct OnboardingStepContent: View {
                                     NightscoutSetupStepView(state: state)
                                 case .connectToNightscout:
                                     NightscoutLoginStepView(state: state)
+                                case .uploadToNightscout:
+                                    NightscoutUploadStepView(state: state)
+                                case .uploadGlucoseToNightscout:
+                                    NightscoutUploadGlucoseStepView(state: state)
                                 case .importFromNightscout:
                                     NightscoutImportStepView(state: state)
                                 }

+ 31 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutUploadGlucoseStepView.swift

@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct NightscoutUploadGlucoseStepView: View {
+    @Bindable var state: Onboarding.StateModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text(
+                "Please choose if you want to upload CGM readings from Trio to Nightscout."
+            )
+            .font(.headline)
+            .padding(.horizontal)
+            .multilineTextAlignment(.leading)
+
+            HStack {
+                Toggle(isOn: $state.uploadGlucose) {
+                    Text("Upload Glucose")
+                }.tint(Color.accentColor)
+            }
+            .padding()
+            .background(Color.chart.opacity(0.65))
+            .cornerRadius(10)
+
+            Text("Enabling this setting allows CGM readings from Trio to be used in Nightscout.")
+                .padding(.horizontal)
+                .font(.footnote)
+                .foregroundStyle(Color.secondary)
+                .multilineTextAlignment(.leading)
+        }
+    }
+}

+ 45 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingSteps/Nightscout/NightscoutUploadStepView.swift

@@ -0,0 +1,45 @@
+import SwiftUI
+
+struct NightscoutUploadStepView: View {
+    @Bindable var state: Onboarding.StateModel
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text(
+                "Please choose if you want to upload treatment data to Nightscout."
+            )
+            .font(.headline)
+            .padding(.horizontal)
+            .multilineTextAlignment(.leading)
+
+            HStack {
+                Toggle(isOn: $state.isUploadEnabled) {
+                    Text("Allow Uploading to Nightscout")
+                }.tint(Color.accentColor)
+            }
+            .padding()
+            .background(Color.chart.opacity(0.65))
+            .cornerRadius(10)
+
+            Text(
+                "The Upload Treatments toggle enables uploading of the following data sets to your connected Nightscout URL:"
+            )
+            .padding(.horizontal)
+            .font(.footnote)
+            .foregroundStyle(Color.secondary)
+            .multilineTextAlignment(.leading)
+
+            VStack(alignment: .leading, spacing: 5) {
+                Text("• Carbs")
+                Text("• Temp Targets")
+                Text("• Device Status")
+                Text("• Preferences")
+                Text("• Settings")
+            }
+            .padding(.horizontal)
+            .font(.footnote)
+            .foregroundStyle(Color.secondary)
+            .multilineTextAlignment(.leading)
+        }
+    }
+}

+ 2 - 0
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -565,6 +565,8 @@ enum NightscoutImportOption: String, Equatable, CaseIterable, Identifiable {
 enum NightscoutSubstep: Int, CaseIterable, Identifiable {
     case setupSelection
     case connectToNightscout
+    case uploadToNightscout
+    case uploadGlucoseToNightscout
     case importFromNightscout
 
     var id: Int { rawValue }

+ 15 - 0
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucosePercentileChart.swift

@@ -36,6 +36,20 @@ struct GlucosePercentileChart: View {
         return hourlyStats.first { Int($0.hour) == hour }
     }
 
+    /// The minimum Y-axis value based on the lowest possible cgm reading
+    private var minYValue: Double {
+        40.0.asUnit(units)
+    }
+
+    /// The maximum Y-axis value based on the highest 90th percentile
+    private var maxYValue: Double {
+        let topLimit = 400.0.asUnit(units)
+        let validStats = hourlyStats.filter { $0.median > 0 }
+        guard !validStats.isEmpty else { return topLimit }
+        let maxPercentile90 = validStats.map(\.percentile90).max() ?? topLimit
+        return maxPercentile90.asUnit(units)
+    }
+
     var body: some View {
         VStack(alignment: .leading, spacing: 8) {
             Text("Ambulatory Glucose Profile (AGP)")
@@ -131,6 +145,7 @@ struct GlucosePercentileChart: View {
                     }
                 }
             }
+            .chartYScale(domain: minYValue ... maxYValue)
             .chartYAxis {
                 AxisMarks(position: .trailing) { value in
                     if let glucose = value.as(Double.self) {

+ 2 - 1
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -18,6 +18,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var storage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseCalendarManager.queue", qos: .background)
@@ -273,7 +274,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             let deltaValue = settingsManager.settings.units == .mmolL ? delta.asMmolL : delta
             let deltaText = deltaFormatter.string(from: deltaValue as NSNumber) ?? "--"
 
-            let iobText = iobFormatter.string(from: (determinationObject.iob ?? 0) as NSNumber) ?? ""
+            let iobText = iobFormatter.string(from: (iobService.currentIOB ?? 0) as NSNumber) ?? ""
             let cobText = cobFormatter.string(from: determinationObject.cob as NSNumber) ?? ""
 
             var glucoseDisplayText = displayEmojis ? glucoseIcon + " " : ""

+ 13 - 1
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -23,6 +23,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     @Injected() private var contactImageStorage: ContactImageStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var fileStorage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     private let contactStore = CNContactStore()
 
@@ -71,6 +72,17 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    await self.updateContactImageState()
+                    await self.updateContactImages()
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -207,7 +219,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
 
             state.lastLoopDate = lastDetermination?.timestamp
 
-            let iobValue = lastDetermination?.iob as? Decimal ?? 0.0
+            let iobValue = iobService.currentIOB ?? 0.0
             state.iob = iobValue
             state.iobText = Formatter.decimalFormatterWithOneFractionDigit.string(from: iobValue as NSNumber)
 

+ 115 - 0
Trio/Sources/Services/IOB/IOBService.swift

@@ -0,0 +1,115 @@
+import Combine
+import CoreData
+import Foundation
+import Swinject
+
+protocol IOBService {
+    var iobPublisher: AnyPublisher<Decimal?, Never> { get }
+    var currentIOB: Decimal? { get }
+    func updateIOB()
+}
+
+/// The single source of truth for current IoB data
+///
+/// The main idea behind this class is that we want one single place to lookup IoB values that is separate
+/// from determinations. Behind the scenes it uses determinations or IoB results stored in the file system
+/// but these are implementation details that we can change with time.
+///
+// TODO: Calculate IoB using APSManager after enough time has elapsed from the last file or determination data
+final class BaseIOBService: IOBService, Injectable {
+    @Injected() private var fileStorage: FileStorage!
+    @Injected() private var determinationStorage: DeterminationStorage!
+    @Injected() private var apsManager: APSManager!
+
+    private let iobSubject = CurrentValueSubject<Decimal?, Never>(nil)
+    var iobPublisher: AnyPublisher<Decimal?, Never> {
+        iobSubject.eraseToAnyPublisher()
+    }
+
+    // Query the current IOB syncrhonously
+    var currentIOB: Decimal? {
+        lookupIOB()
+    }
+
+    private var subscriptions = Set<AnyCancellable>()
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+    private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
+    private let context = CoreDataStack.shared.newTaskContext()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: queue)
+                .share()
+                .eraseToAnyPublisher()
+        subscribe()
+    }
+
+    private func subscribe() {
+        // Trigger update when a new determination is available
+        coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
+            self?.updateIOB()
+        }.store(in: &subscriptions)
+
+        // Trigger update when the iob file is updated
+        apsManager.iobFileDidUpdate
+            .sink { [weak self] _ in
+                self?.updateIOB()
+            }
+            .store(in: &subscriptions)
+    }
+
+    // Fetches the IoB and timestamp from the most recent determination
+    private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
+        var iob: Decimal?
+        var date: Date?
+        context.performAndWait {
+            let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
+            request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]
+            request.fetchLimit = 1
+            if let determination = try? context.fetch(request).first {
+                iob = determination.iob as? Decimal
+                date = determination.deliverAt
+            }
+        }
+        return (iob, date)
+    }
+
+    // Lookup IOB data from the file system and determinations core data, use the most
+    // recent value
+    func lookupIOB() -> Decimal? {
+        let iobFromFile = fileStorage.retrieve(OpenAPS.Monitor.iob, as: [IOBEntry].self)
+        let iobFromFileValue = iobFromFile?.first?.iob
+        let iobFromFileDate = iobFromFile?.first?.time
+
+        let (iobFromDetermination, iobFromDeterminationDate) = fetchLatestDeterminationIOB()
+
+        var mostRecentIOB: Decimal?
+
+        if let iobFromFileValue = iobFromFileValue, let iobFromFileDate = iobFromFileDate {
+            if let iobFromDetermination = iobFromDetermination, let iobFromDeterminationDate = iobFromDeterminationDate {
+                if iobFromFileDate > iobFromDeterminationDate {
+                    mostRecentIOB = iobFromFileValue
+                } else {
+                    mostRecentIOB = iobFromDetermination
+                }
+            } else {
+                mostRecentIOB = iobFromFileValue
+            }
+        } else {
+            mostRecentIOB = iobFromDetermination
+        }
+
+        return mostRecentIOB
+    }
+
+    func updateIOB() {
+        Task {
+            let mostRecentIOB = lookupIOB()
+            if iobSubject.value != mostRecentIOB {
+                iobSubject.send(mostRecentIOB)
+            }
+        }
+    }
+}

+ 1 - 2
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -34,7 +34,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["iob", "cob", "currentTarget", "deliverAt"]
+            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
         )
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -60,7 +60,6 @@ extension LiveActivityManager {
 
             return DeterminationData(
                 cob: (determination["cob"] as? Int) ?? 0,
-                iob: (determination["iob"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 tdd: tddValue,
                 target: (determination["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 date: determination["deliverAt"] as? Date ?? nil

+ 0 - 1
Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -2,7 +2,6 @@ import Foundation
 
 struct DeterminationData {
     let cob: Int
-    let iob: Decimal
     let tdd: Decimal
     let target: Decimal
     let date: Date?

+ 2 - 1
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -62,6 +62,7 @@ extension LiveActivityAttributes.ContentState {
         chart: [GlucoseData],
         settings: TrioSettings,
         determination: DeterminationData?,
+        iob: Decimal?,
         override: OverrideData?,
         widgetItems: [LiveActivityAttributes.LiveActivityItem]?
     ) {
@@ -108,7 +109,7 @@ extension LiveActivityAttributes.ContentState {
                 chartDate: chartDate,
                 rotationDegrees: rotationDegrees,
                 cob: Decimal(determination?.cob ?? 0),
-                iob: determination?.iob ?? 0 as Decimal,
+                iob: iob ?? 0 as Decimal,
                 tdd: determination?.tdd ?? 0 as Decimal,
                 isOverrideActive: override?.isActive ?? false,
                 overrideName: override?.overrideName ?? "Override",

+ 10 - 0
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -29,6 +29,8 @@ import UIKit
 final class LiveActivityData: ObservableObject {
     /// Determination data used to update live activity state.
     @Published var determination: DeterminationData?
+    /// The most recent IoB data
+    @Published var iob: Decimal?
     /// Array of glucose readings fetched from persistent storage.
     @Published var glucoseFromPersistence: [GlucoseData]?
     /// The current override data (if any).
@@ -51,6 +53,7 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var storage: FileStorage!
     @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var iobService: IOBService!
 
     private let activityAuthorizationInfo = ActivityAuthorizationInfo()
     /// Indicates whether system live activities are enabled.
@@ -147,6 +150,12 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
             .sink { [weak self] _ in
                 Task { await self?.loadDetermination() }
             }.store(in: &subscriptions)
+
+        iobService.iobPublisher
+            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
+            .sink { [weak self] _ in
+                self?.data.iob = self?.iobService.currentIOB
+            }.store(in: &subscriptions)
     }
 
     /// Fetches and maps new determination data and updates the live activity content state.
@@ -374,6 +383,7 @@ extension LiveActivityManager {
             chart: glucose,
             settings: settings,
             determination: determination,
+            iob: data.iob,
             override: data.override,
             widgetItems: data.widgetItems
         )

+ 3 - 25
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -18,7 +18,9 @@ extension TrioRemoteControl {
         }
 
         let maxIOB = settings.preferences.maxIOB
-        let currentIOB = try await fetchCurrentIOB()
+        guard let currentIOB = iobService.currentIOB else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
         if (currentIOB + bolusAmount) > maxIOB {
             await logError(
                 "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
@@ -56,30 +58,6 @@ extension TrioRemoteControl {
         )
     }
 
-    private func fetchCurrentIOB() async throws -> Decimal {
-        let predicate = NSPredicate.predicateFor30MinAgoForDetermination
-
-        let determinations = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: predicate,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1,
-            propertiesToFetch: ["iob"]
-        )
-
-        guard let fetchedResults = determinations as? [[String: Any]],
-              let firstResult = fetchedResults.first,
-              let iob = firstResult["iob"] as? Decimal
-        else {
-            await logError("Failed to fetch current IOB.")
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-
-        return iob
-    }
-
     private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",

+ 1 - 0
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,6 +10,7 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
+    @Injected() internal var iobService: IOBService!
 
     private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
 

+ 15 - 3
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -24,6 +24,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var overrideStorage: OverrideStorage!
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
+    @Injected() private var iobService: IOBService!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -79,6 +80,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    let state = await self.setupWatchState()
+                    await self.sendDataToWatch(state)
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -201,10 +213,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 }
 
                 // Set IOB and COB from latest determination
-                if let latestDetermination = determinationObjects.first {
-                    let iob = latestDetermination.iob ?? 0
-                    watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob)
+                let iob = self.iobService.currentIOB ?? 0
+                watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob as NSNumber)
 
+                if let latestDetermination = determinationObjects.first {
                     let cob = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cob)
                 }

+ 24 - 3
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -53,6 +53,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Stores, retrieves, and updates insulin dose determinations in CoreData.
     @Injected() private var determinationStorage: DeterminationStorage!
 
+    @Injected() private var iobService: IOBService!
+
     /// Persists the user's device list between app launches.
     @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
 
@@ -150,6 +152,25 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    do {
+                        let watchState = try await self.setupGarminWatchState()
+                        let watchStateData = try JSONEncoder().encode(watchState)
+                        self.sendWatchStateData(watchStateData)
+                    } catch {
+                        debug(
+                            .watchManager,
+                            "\(DebuggingIdentifiers.failed) Error updating watch state: \(error)"
+                        )
+                    }
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -250,15 +271,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 var watchState = GarminWatchState()
 
                 /// Pull `glucose`, `trendRaw`, `delta`, `lastLoopDateInterval`, `iob`, `cob`,  `isf`, and `eventualBGRaw` from the latest determination.
+                let iobValue = self.iobService.currentIOB ?? 0
+                watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue)
+
                 if let latestDetermination = determinationObjects.first {
                     watchState.lastLoopDateInterval = latestDetermination.timestamp.map {
                         guard $0.timeIntervalSince1970 > 0 else { return 0 }
                         return UInt64($0.timeIntervalSince1970)
                     }
 
-                    let iobValue = latestDetermination.iob ?? 0
-                    watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue as Decimal)
-
                     let cobNumber = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
 

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -16,6 +16,7 @@ import Swinject
     @Injected() var overrideStorage: OverrideStorage!
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() var iobService: IOBService!
 
     let resolver: Resolver
 

+ 1 - 1
Trio/Sources/Shortcuts/State/StateIntentRequest.swift

@@ -111,7 +111,7 @@ final class StateIntentRequest: BaseIntentsRequest {
             fetchLimit: 1
         ) as? [OrefDetermination] ?? []
 
-        let iobAsDouble = Double(truncating: (results.first?.iob ?? 0.0) as NSNumber)
+        let iobAsDouble = Double(truncating: (iobService.currentIOB ?? 0.0) as NSNumber)
         let cobAsDouble = Double(truncating: (results.first?.cob ?? 0) as NSNumber)
 
         return (iobAsDouble, cobAsDouble)