浏览代码

Recommended bolus for Trio (#466)

Jonas Björkert 7 月之前
父节点
当前提交
0b47111987

+ 10 - 2
LoopFollow.xcodeproj/project.pbxproj

@@ -16,11 +16,13 @@
 		656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; };
 		656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; };
 		656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; };
 		656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */; };
 		656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; };
 		656F8C142E49F3D20008DC1D /* RemoteCommandSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */; };
-		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
 		6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; };
+		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
+		DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; };
+		DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */; };
 		DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
 		DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; };
 		DD0650EB2DCE8385004D3B41 /* LowBGCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650EA2DCE8385004D3B41 /* LowBGCondition.swift */; };
 		DD0650EB2DCE8385004D3B41 /* LowBGCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650EA2DCE8385004D3B41 /* LowBGCondition.swift */; };
 		DD0650ED2DCE9371004D3B41 /* HighBgAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650EC2DCE9371004D3B41 /* HighBgAlarmEditor.swift */; };
 		DD0650ED2DCE9371004D3B41 /* HighBgAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650EC2DCE9371004D3B41 /* HighBgAlarmEditor.swift */; };
@@ -408,12 +410,14 @@
 		656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
 		656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeDisplayView.swift; sourceTree = "<group>"; };
 		656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
 		656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = "<group>"; };
 		656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
 		656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = "<group>"; };
-		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
 		6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = "<group>"; };
+		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
 		DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = "<group>"; };
+		DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = "<group>"; };
+		DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinFormatter.swift; sourceTree = "<group>"; };
 		DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmBGSection.swift; sourceTree = "<group>"; };
 		DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmBGSection.swift; sourceTree = "<group>"; };
 		DD0650EA2DCE8385004D3B41 /* LowBGCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowBGCondition.swift; sourceTree = "<group>"; };
 		DD0650EA2DCE8385004D3B41 /* LowBGCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowBGCondition.swift; sourceTree = "<group>"; };
 		DD0650EC2DCE9371004D3B41 /* HighBgAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighBgAlarmEditor.swift; sourceTree = "<group>"; };
 		DD0650EC2DCE9371004D3B41 /* HighBgAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighBgAlarmEditor.swift; sourceTree = "<group>"; };
@@ -1505,6 +1509,8 @@
 		FCC688542489367300A0279D /* Helpers */ = {
 		FCC688542489367300A0279D /* Helpers */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */,
+				DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */,
 				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
 				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
 				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
 				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
@@ -1971,6 +1977,7 @@
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
 				DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */,
 				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DDEF503A2D31615000999A5D /* LogManager.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
 				DD4878172C7B75350048F05C /* BolusView.swift in Sources */,
+				DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */,
 				DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
 				DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
 				FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */,
@@ -2085,6 +2092,7 @@
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
 				DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */,
 				DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */,
 				DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
 				DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */,
+				DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */,
 				DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
 				DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
 				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */,
 				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */,

+ 40 - 19
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -3,6 +3,7 @@
 
 
 import Charts
 import Charts
 import Foundation
 import Foundation
+import HealthKit
 import UIKit
 import UIKit
 
 
 extension MainViewController {
 extension MainViewController {
@@ -95,8 +96,28 @@ extension MainViewController {
                                    .withTime,
                                    .withTime,
                                    .withDashSeparatorInDate,
                                    .withDashSeparatorInDate,
                                    .withColonSeparatorInTime]
                                    .withColonSeparatorInTime]
+
+        Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value
+
         if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? {
         if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? {
-            if let lastPumpTime = formatter.date(from: (lastPumpRecord["clock"] as! String))?.timeIntervalSince1970 {
+            if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 {
+                Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement)
+                Storage.shared.bolusIncrementDetected.value = true
+            } else if let model = lastPumpRecord["model"] as? String, model == "Dash" {
+                Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)
+                Storage.shared.bolusIncrementDetected.value = true
+            } else {
+                Storage.shared.bolusIncrementDetected.value = false
+            }
+
+            if let clockString = lastPumpRecord["clock"] as? String,
+               let lastPumpTime = formatter.date(from: clockString)?.timeIntervalSince1970
+            {
+                let storedTime = Observable.shared.alertLastLoopTime.value ?? 0
+                if lastPumpTime > storedTime {
+                    Observable.shared.alertLastLoopTime.value = lastPumpTime
+                }
+
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                 if let reservoirData = lastPumpRecord["reservoir"] as? Double {
                     latestPumpVolume = reservoirData
                     latestPumpVolume = reservoirData
                     infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
                     infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
@@ -104,27 +125,27 @@ extension MainViewController {
                     latestPumpVolume = 50.0
                     latestPumpVolume = 50.0
                     infoManager.updateInfoData(type: .pump, value: "50+U")
                     infoManager.updateInfoData(type: .pump, value: "50+U")
                 }
                 }
+            }
 
 
-                if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
-                   let upbat = uploader["battery"] as? Double
-                {
-                    let batteryText: String
-                    if let isCharging = uploader["isCharging"] as? Bool, isCharging {
-                        batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
-                    } else {
-                        batteryText = String(format: "%.0f", upbat) + "%"
-                    }
-                    infoManager.updateInfoData(type: .battery, value: batteryText)
-                    Observable.shared.deviceBatteryLevel.value = upbat
+            if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject],
+               let upbat = uploader["battery"] as? Double
+            {
+                let batteryText: String
+                if let isCharging = uploader["isCharging"] as? Bool, isCharging {
+                    batteryText = "⚡️ " + String(format: "%.0f", upbat) + "%"
+                } else {
+                    batteryText = String(format: "%.0f", upbat) + "%"
+                }
+                infoManager.updateInfoData(type: .battery, value: batteryText)
+                Observable.shared.deviceBatteryLevel.value = upbat
 
 
-                    let timestamp = uploader["timestamp"] as? Date ?? Date()
-                    let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
-                    deviceBatteryData.append(currentBattery)
+                let timestamp = uploader["timestamp"] as? Date ?? Date()
+                let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
+                deviceBatteryData.append(currentBattery)
 
 
-                    // store only the last 30 battery readings
-                    if deviceBatteryData.count > 30 {
-                        deviceBatteryData.removeFirst()
-                    }
+                // store only the last 30 battery readings
+                if deviceBatteryData.count > 30 {
+                    deviceBatteryData.removeFirst()
                 }
                 }
             }
             }
         }
         }

+ 89 - 91
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -14,112 +14,110 @@ extension MainViewController {
             Storage.shared.remoteType.value = .none
             Storage.shared.remoteType.value = .none
         }
         }
 
 
-        if let lastLoopTime = formatter.date(from: (lastLoopRecord["timestamp"] as! String))?.timeIntervalSince1970 {
-            let previousLastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0
-            Observable.shared.alertLastLoopTime.value = lastLoopTime
-            if let failure = lastLoopRecord["failureReason"] {
-                LoopStatusLabel.text = "X"
-                latestLoopStatusString = "X"
-            } else {
-                var wasEnacted = false
-                if let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] {
-                    wasEnacted = true
-                    if let lastTempBasal = enacted["rate"] as? Double {}
-                }
+        let previousLastLoopTime = Observable.shared.previousAlertLastLoopTime.value ?? 0
+        let lastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0
 
 
-                // ISF
-                let profileISF = profileManager.currentISF()
-                if let profileISF = profileISF {
-                    infoManager.updateInfoData(type: .isf, value: profileISF)
-                }
+        if lastLoopRecord["failureReason"] != nil {
+            LoopStatusLabel.text = "X"
+            latestLoopStatusString = "X"
+        } else {
+            var wasEnacted = false
+            if lastLoopRecord["enacted"] is [String: AnyObject] {
+                wasEnacted = true
+            }
 
 
-                // Carb Ratio (CR)
-                let profileCR = profileManager.currentCarbRatio()
-                if let profileCR = profileCR {
-                    infoManager.updateInfoData(type: .carbRatio, value: profileCR)
-                }
+            // ISF
+            let profileISF = profileManager.currentISF()
+            if let profileISF = profileISF {
+                infoManager.updateInfoData(type: .isf, value: profileISF)
+            }
 
 
-                // Target
-                let profileTargetLow = profileManager.currentTargetLow()
-                let profileTargetHigh = profileManager.currentTargetHigh()
+            // Carb Ratio (CR)
+            let profileCR = profileManager.currentCarbRatio()
+            if let profileCR = profileCR {
+                infoManager.updateInfoData(type: .carbRatio, value: profileCR)
+            }
 
 
-                if let profileTargetLow = profileTargetLow, let profileTargetHigh = profileTargetHigh, profileTargetLow != profileTargetHigh {
-                    infoManager.updateInfoData(type: .target, firstValue: profileTargetLow, secondValue: profileTargetHigh, separator: .dash)
-                } else if let profileTargetLow = profileTargetLow {
-                    infoManager.updateInfoData(type: .target, value: profileTargetLow)
-                }
+            // Target
+            let profileTargetLow = profileManager.currentTargetLow()
+            let profileTargetHigh = profileManager.currentTargetHigh()
 
 
-                // IOB
-                if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
-                    infoManager.updateInfoData(type: .iob, value: insulinMetric)
-                    latestIOB = insulinMetric
-                }
+            if let profileTargetLow = profileTargetLow, let profileTargetHigh = profileTargetHigh, profileTargetLow != profileTargetHigh {
+                infoManager.updateInfoData(type: .target, firstValue: profileTargetLow, secondValue: profileTargetHigh, separator: .dash)
+            } else if let profileTargetLow = profileTargetLow {
+                infoManager.updateInfoData(type: .target, value: profileTargetLow)
+            }
 
 
-                // COB
-                if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") {
-                    infoManager.updateInfoData(type: .cob, value: cobMetric)
-                    latestCOB = cobMetric
-                }
+            // IOB
+            if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
+                infoManager.updateInfoData(type: .iob, value: insulinMetric)
+                latestIOB = insulinMetric
+            }
 
 
-                if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] {
-                    let prediction = predictdata["values"] as! [Double]
-                    PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!)))
-                    PredictionLabel.textColor = UIColor.systemPurple
-                    if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
-                        predictionData.removeAll()
-                        var predictionTime = lastLoopTime
-                        let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
-                        var i = 0
-                        while i <= toLoad {
-                            if i < prediction.count {
-                                let sgvValue = Int(round(prediction[i]))
-                                // Skip values higher than 600
-                                if sgvValue <= 600 {
-                                    let prediction = ShareGlucoseData(sgv: sgvValue, date: predictionTime, direction: "flat")
-                                    predictionData.append(prediction)
-                                }
-                                predictionTime += 300
-                            }
-                            i += 1
-                        }
+            // COB
+            if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") {
+                infoManager.updateInfoData(type: .cob, value: cobMetric)
+                latestCOB = cobMetric
+            }
 
 
-                        if let predMin = prediction.min(), let predMax = prediction.max() {
-                            let formattedMin = Localizer.toDisplayUnits(String(predMin))
-                            let formattedMax = Localizer.toDisplayUnits(String(predMax))
-                            let value = "\(formattedMin)/\(formattedMax)"
-                            infoManager.updateInfoData(type: .minMax, value: value)
+            if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] {
+                let prediction = predictdata["values"] as! [Double]
+                PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!)))
+                PredictionLabel.textColor = UIColor.systemPurple
+                if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime {
+                    predictionData.removeAll()
+                    var predictionTime = lastLoopTime
+                    let toLoad = Int(Storage.shared.predictionToLoad.value * 12)
+                    var i = 0
+                    while i <= toLoad {
+                        if i < prediction.count {
+                            let sgvValue = Int(round(prediction[i]))
+                            // Skip values higher than 600
+                            if sgvValue <= 600 {
+                                let prediction = ShareGlucoseData(sgv: sgvValue, date: predictionTime, direction: "flat")
+                                predictionData.append(prediction)
+                            }
+                            predictionTime += 300
                         }
                         }
+                        i += 1
+                    }
 
 
-                        updatePredictionGraph()
+                    if let predMin = prediction.min(), let predMax = prediction.max() {
+                        let formattedMin = Localizer.toDisplayUnits(String(predMin))
+                        let formattedMax = Localizer.toDisplayUnits(String(predMax))
+                        let value = "\(formattedMin)/\(formattedMax)"
+                        infoManager.updateInfoData(type: .minMax, value: value)
                     }
                     }
-                } else {
-                    predictionData.removeAll()
-                    infoManager.clearInfoData(type: .minMax)
+
                     updatePredictionGraph()
                     updatePredictionGraph()
                 }
                 }
-                if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
-                    let formattedRecBolus = String(format: "%.2fU", recBolus)
-                    infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus)
-                    Observable.shared.deviceRecBolus.value = recBolus
-                }
-                if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
-                    if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
-                        var lastBGTime = lastLoopTime
-                        if bgData.count > 0 {
-                            lastBGTime = bgData[bgData.count - 1].date
-                        }
-                        if tempBasalTime > lastBGTime, !wasEnacted {
-                            LoopStatusLabel.text = "⏀"
-                            latestLoopStatusString = "⏀"
-                        } else {
-                            LoopStatusLabel.text = "↻"
-                            latestLoopStatusString = "↻"
-                        }
+            } else {
+                predictionData.removeAll()
+                infoManager.clearInfoData(type: .minMax)
+                updatePredictionGraph()
+            }
+            if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
+                let formattedRecBolus = String(format: "%.2fU", recBolus)
+                infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus)
+                Observable.shared.deviceRecBolus.value = recBolus
+            }
+            if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
+                if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 {
+                    var lastBGTime = lastLoopTime
+                    if bgData.count > 0 {
+                        lastBGTime = bgData[bgData.count - 1].date
+                    }
+                    if tempBasalTime > lastBGTime, !wasEnacted {
+                        LoopStatusLabel.text = "⏀"
+                        latestLoopStatusString = "⏀"
+                    } else {
+                        LoopStatusLabel.text = "↻"
+                        latestLoopStatusString = "↻"
                     }
                     }
-                } else {
-                    LoopStatusLabel.text = "↻"
-                    latestLoopStatusString = "↻"
                 }
                 }
+            } else {
+                LoopStatusLabel.text = "↻"
+                latestLoopStatusString = "↻"
             }
             }
         }
         }
     }
     }

+ 10 - 20
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -18,25 +18,6 @@ extension MainViewController {
                 return
                 return
             }
             }
 
 
-            var wasEnacted: Bool
-            if let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] {
-                wasEnacted = true
-                if let timestampString = enacted["timestamp"] as? String,
-                   let lastLoopTime = formatter.date(from: timestampString)?.timeIntervalSince1970
-                {
-                    let storedTime = Observable.shared.alertLastLoopTime.value ?? 0
-                    if lastLoopTime < storedTime {
-                        LogManager.shared.log(category: .deviceStatus, message: "Received an old timestamp for enacted: \(lastLoopTime) is older than last stored time \(storedTime), ignoring update.", isDebug: false)
-                    } else {
-                        Observable.shared.alertLastLoopTime.value = lastLoopTime
-                        LogManager.shared.log(category: .deviceStatus, message: "New LastLoopTime: \(lastLoopTime)", isDebug: true)
-                    }
-                }
-            } else {
-                wasEnacted = false
-                LogManager.shared.log(category: .deviceStatus, message: "Last devicestatus is missing enacted")
-            }
-
             var updatedTime: TimeInterval?
             var updatedTime: TimeInterval?
 
 
             if let timestamp = enactedOrSuggested["timestamp"] as? String,
             if let timestamp = enactedOrSuggested["timestamp"] as? String,
@@ -45,6 +26,7 @@ extension MainViewController {
                 updatedTime = parsedTime
                 updatedTime = parsedTime
                 let formattedTime = Localizer.formatTimestampToLocalString(parsedTime)
                 let formattedTime = Localizer.formatTimestampToLocalString(parsedTime)
                 infoManager.updateInfoData(type: .updated, value: formattedTime)
                 infoManager.updateInfoData(type: .updated, value: formattedTime)
+                Observable.shared.enactedOrSuggested.value = updatedTime
             }
             }
 
 
             // ISF
             // ISF
@@ -122,6 +104,14 @@ extension MainViewController {
                 infoManager.updateInfoData(type: .autosens, value: formattedSens)
                 infoManager.updateInfoData(type: .autosens, value: formattedSens)
             }
             }
 
 
+            // Recommended Bolus
+            if let rec = InsulinMetric(from: lastLoopRecord, key: "recommendedBolus") {
+                infoManager.updateInfoData(type: .recBolus, value: rec)
+                Observable.shared.deviceRecBolus.value = rec.value
+            } else {
+                Observable.shared.deviceRecBolus.value = nil
+            }
+
             // Eventual BG
             // Eventual BG
             if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double {
             if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double {
                 let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
                 let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
@@ -226,7 +216,7 @@ extension MainViewController {
                     if bgData.count > 0 {
                     if bgData.count > 0 {
                         lastBGTime = bgData[bgData.count - 1].date
                         lastBGTime = bgData[bgData.count - 1].date
                     }
                     }
-                    if tempBasalTime > lastBGTime, !wasEnacted {
+                    if tempBasalTime > lastBGTime {
                         LoopStatusLabel.text = "⏀"
                         LoopStatusLabel.text = "⏀"
                         latestLoopStatusString = "⏀"
                         latestLoopStatusString = "⏀"
                     } else {
                     } else {

+ 1 - 1
LoopFollow/Extensions/HKUnit+Extensions.swift

@@ -18,7 +18,7 @@ extension HKUnit {
         case .millimolesPerLiter:
         case .millimolesPerLiter:
             return 1
             return 1
         case .internationalUnit():
         case .internationalUnit():
-            return 3
+            return InsulinPrecisionManager.shared.fractionDigits
         default:
         default:
             return 0
             return 0
         }
         }

+ 24 - 0
LoopFollow/Helpers/InsulinFormatter.swift

@@ -0,0 +1,24 @@
+// LoopFollow
+// InsulinFormatter.swift
+
+import Foundation
+import HealthKit
+
+final class InsulinFormatter {
+    static let shared = InsulinFormatter()
+    private let precision = InsulinPrecisionManager.shared
+    private init() {}
+
+    func string(_ q: HKQuantity) -> String {
+        string(q.doubleValue(for: .internationalUnit()))
+    }
+
+    func string(_ units: Double) -> String {
+        let fd = precision.fractionDigits
+        let nf = NumberFormatter()
+        nf.minimumFractionDigits = fd
+        nf.maximumFractionDigits = fd
+        nf.numberStyle = .decimal
+        return nf.string(from: NSNumber(value: units)) ?? String(format: "%.\(fd)f", units)
+    }
+}

+ 34 - 0
LoopFollow/Helpers/InsulinPrecisionManager.swift

@@ -0,0 +1,34 @@
+// LoopFollow
+// InsulinPrecisionManager.swift
+
+import Combine
+import Foundation
+import HealthKit
+
+final class InsulinPrecisionManager: ObservableObject {
+    static let shared = InsulinPrecisionManager()
+
+    @Published private(set) var fractionDigits: Int = 3
+    private var cancellables = Set<AnyCancellable>()
+
+    private init() {
+        fractionDigits = Self.computeDigits(from: Storage.shared.bolusIncrement.value)
+
+        Storage.shared.bolusIncrement.$value
+            .map(Self.computeDigits(from:))
+            .removeDuplicates()
+            .receive(on: DispatchQueue.main)
+            .assign(to: &$fractionDigits)
+    }
+
+    private static func computeDigits(from q: HKQuantity) -> Int {
+        let step = max(0.001, q.doubleValue(for: .internationalUnit()))
+        if step >= 1 { return 0 }
+        var v = step
+        var d = 0
+        while d < 6 && abs(round(v) - v) > 1e-10 {
+            v *= 10; d += 1
+        }
+        return min(max(d, 0), 5)
+    }
+}

+ 1 - 1
LoopFollow/Helpers/TextFieldWithToolBar.swift

@@ -206,7 +206,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
             let value = quantity.doubleValue(for: unit)
             let value = quantity.doubleValue(for: unit)
             let formatter = NumberFormatter()
             let formatter = NumberFormatter()
             formatter.minimumFractionDigits = unit.preferredFractionDigits
             formatter.minimumFractionDigits = unit.preferredFractionDigits
-            formatter.maximumFractionDigits = unit.preferredFractionDigits
+            formatter.maximumFractionDigits = max(unit.preferredFractionDigits, 3)
             formatter.numberStyle = .decimal
             formatter.numberStyle = .decimal
             return formatter.string(from: NSNumber(value: value)) ?? ""
             return formatter.string(from: NSNumber(value: value)) ?? ""
         }
         }

+ 2 - 1
LoopFollow/Helpers/Views/HKQuantityInputView.swift

@@ -13,9 +13,10 @@ struct HKQuantityInputView: View {
     var minValue: HKQuantity
     var minValue: HKQuantity
     var maxValue: HKQuantity
     var maxValue: HKQuantity
     @FocusState.Binding var isFocused: Bool
     @FocusState.Binding var isFocused: Bool
-
     var onValidationError: (String) -> Void
     var onValidationError: (String) -> Void
 
 
+    @ObservedObject private var insulinPrecision = InsulinPrecisionManager.shared
+
     var body: some View {
     var body: some View {
         HStack {
         HStack {
             Text(label)
             Text(label)

+ 31 - 1
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -7,6 +7,7 @@ import SwiftUI
 struct RemoteSettingsView: View {
 struct RemoteSettingsView: View {
     @ObservedObject var viewModel: RemoteSettingsViewModel
     @ObservedObject var viewModel: RemoteSettingsViewModel
     @ObservedObject private var device = Storage.shared.device
     @ObservedObject private var device = Storage.shared.device
+    @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement
 
 
     @State private var showAlert: Bool = false
     @State private var showAlert: Bool = false
     @State private var alertType: AlertType? = nil
     @State private var alertType: AlertType? = nil
@@ -109,6 +110,29 @@ struct RemoteSettingsView: View {
                 guardrailsSection
                 guardrailsSection
             }
             }
 
 
+            if !Storage.shared.bolusIncrementDetected.value {
+                Section(header: Text("Bolus Increment")) {
+                    HStack {
+                        Text("Increment")
+                        Spacer()
+                        TextFieldWithToolBar(
+                            quantity: $bolusIncrement.value,
+                            maxLength: 5,
+                            unit: HKUnit.internationalUnit(),
+                            allowDecimalSeparator: true,
+                            minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.001),
+                            maxValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1),
+                            onValidationError: { message in
+                                handleValidationError(message)
+                            }
+                        )
+                        .frame(width: 100)
+                        Text("U")
+                            .foregroundColor(.secondary)
+                    }
+                }
+            }
+
             // MARK: - User Information Section
             // MARK: - User Information Section
 
 
             if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS {
             if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS {
@@ -160,9 +184,12 @@ struct RemoteSettingsView: View {
 
 
                 Section(header: Text("Debug / Info")) {
                 Section(header: Text("Debug / Info")) {
                     Text("Device Token: \(Storage.shared.deviceToken.value)")
                     Text("Device Token: \(Storage.shared.deviceToken.value)")
-                    Text("Production Env.: \(Storage.shared.productionEnvironment.value ? "True" : "False")")
+                    Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")")
                     Text("Team ID: \(Storage.shared.teamId.value ?? "")")
                     Text("Team ID: \(Storage.shared.teamId.value ?? "")")
                     Text("Bundle ID: \(Storage.shared.bundleId.value)")
                     Text("Bundle ID: \(Storage.shared.bundleId.value)")
+                    if Storage.shared.bolusIncrementDetected.value {
+                        Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
+                    }
                 }
                 }
             }
             }
 
 
@@ -260,6 +287,9 @@ struct RemoteSettingsView: View {
                         Text("TOTP Code: Invalid QR code URL")
                         Text("TOTP Code: Invalid QR code URL")
                             .foregroundColor(.red)
                             .foregroundColor(.red)
                     }
                     }
+                    if Storage.shared.bolusIncrementDetected.value {
+                        Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U")
+                    }
                 }
                 }
 
 
                 if viewModel.areTeamIdsDifferent {
                 if viewModel.areTeamIdsDifferent {

+ 156 - 10
LoopFollow/Remote/TRC/BolusView.swift

@@ -7,36 +7,73 @@ import SwiftUI
 
 
 struct BolusView: View {
 struct BolusView: View {
     @Environment(\.presentationMode) private var presentationMode
     @Environment(\.presentationMode) private var presentationMode
+
     @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
     @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
-    private let pushNotificationManager = PushNotificationManager()
     @ObservedObject private var maxBolus = Storage.shared.maxBolus
     @ObservedObject private var maxBolus = Storage.shared.maxBolus
+    @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement
 
 
-    @FocusState private var bolusFieldIsFocused: Bool
+    @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus
+    @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested
 
 
+    @FocusState private var bolusFieldIsFocused: Bool
     @State private var showAlert = false
     @State private var showAlert = false
     @State private var alertType: AlertType? = nil
     @State private var alertType: AlertType? = nil
     @State private var alertMessage: String? = nil
     @State private var alertMessage: String? = nil
     @State private var isLoading = false
     @State private var isLoading = false
     @State private var statusMessage: String? = nil
     @State private var statusMessage: String? = nil
 
 
+    private let pushNotificationManager = PushNotificationManager()
+
     enum AlertType {
     enum AlertType {
         case confirmBolus
         case confirmBolus
         case statusSuccess
         case statusSuccess
         case statusFailure
         case statusFailure
         case validation
         case validation
+        case oldCalculationWarning
+    }
+
+    // MARK: - Step/precision helpers driven by stored increment
+
+    private var stepU: Double {
+        max(0.001, bolusIncrement.value.doubleValue(for: .internationalUnit()))
     }
     }
 
 
+    private var stepFractionDigits: Int {
+        let inc = stepU
+        if inc >= 1 { return 0 }
+        var v = inc
+        var digits = 0
+        while digits < 6 && abs(round(v) - v) > 1e-10 {
+            v *= 10; digits += 1
+        }
+        return min(max(digits, 0), 5)
+    }
+
+    private func roundedToStep(_ value: Double) -> Double {
+        guard stepU > 0 else { return value }
+
+        let epsilon = 1e-10
+        let stepped = ((value / stepU) + epsilon).rounded(.down) * stepU
+
+        let p = pow(10.0, Double(stepFractionDigits))
+        return (stepped * p).rounded() / p
+    }
+
+    // MARK: - View
+
     var body: some View {
     var body: some View {
         NavigationView {
         NavigationView {
-            VStack {
+            TimelineView(.periodic(from: .now, by: 1)) { context in
                 Form {
                 Form {
+                    recommendedBlocks(now: context.date)
+
                     Section {
                     Section {
                         HKQuantityInputView(
                         HKQuantityInputView(
                             label: "Bolus Amount",
                             label: "Bolus Amount",
                             quantity: $bolusAmount,
                             quantity: $bolusAmount,
                             unit: .internationalUnit(),
                             unit: .internationalUnit(),
-                            maxLength: 4,
-                            minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05),
+                            maxLength: 5,
+                            minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
                             maxValue: maxBolus.value,
                             maxValue: maxBolus.value,
                             isFocused: $bolusFieldIsFocused,
                             isFocused: $bolusFieldIsFocused,
                             onValidationError: { message in
                             onValidationError: { message in
@@ -52,7 +89,11 @@ struct BolusView: View {
                         action: {
                         action: {
                             bolusFieldIsFocused = false
                             bolusFieldIsFocused = false
                             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
-                                if bolusAmount.doubleValue(for: HKUnit.internationalUnit()) > 0.0 {
+                                let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit())
+                                let steppedAmount = roundedToStep(rawValue)
+
+                                if steppedAmount > 0 {
+                                    bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount)
                                     alertType = .confirmBolus
                                     alertType = .confirmBolus
                                     showAlert = true
                                     showAlert = true
                                 }
                                 }
@@ -69,7 +110,7 @@ struct BolusView: View {
                 case .confirmBolus:
                 case .confirmBolus:
                     return Alert(
                     return Alert(
                         title: Text("Confirm Bolus"),
                         title: Text("Confirm Bolus"),
-                        message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: HKUnit.internationalUnit()), specifier: "%.2f") U?"),
+                        message: Text("Are you sure you want to send \(InsulinFormatter.shared.string(bolusAmount)) U?"),
                         primaryButton: .default(Text("Confirm"), action: {
                         primaryButton: .default(Text("Confirm"), action: {
                             AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
                             AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in
                                 if case .success = result {
                                 if case .success = result {
@@ -99,6 +140,17 @@ struct BolusView: View {
                         message: Text(alertMessage ?? "Invalid input."),
                         message: Text(alertMessage ?? "Invalid input."),
                         dismissButton: .default(Text("OK"))
                         dismissButton: .default(Text("OK"))
                     )
                     )
+                case .oldCalculationWarning:
+                    return Alert(
+                        title: Text("Old Calculation Warning"),
+                        message: Text(alertMessage ?? ""),
+                        primaryButton: .default(Text("Use Anyway")) {
+                            if let rec = deviceRecBolus.value {
+                                applyRecommendedBolus(rec)
+                            }
+                        },
+                        secondaryButton: .cancel()
+                    )
                 case .none:
                 case .none:
                     return Alert(title: Text("Unknown Alert"))
                     return Alert(title: Text("Unknown Alert"))
                 }
                 }
@@ -106,20 +158,114 @@ struct BolusView: View {
         }
         }
     }
     }
 
 
+    // MARK: - Recommended bolus UI
+
+    @ViewBuilder
+    private func recommendedBlocks(now: Date) -> some View {
+        if let rec = deviceRecBolus.value,
+           let t = enactedOrSuggested.value
+        {
+            let ageSec = max(0, now.timeIntervalSince1970 - t)
+
+            if ageSec < 12 * 60 {
+                let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
+                let clamped = min(rec, maxU)
+                let steppedRec = roundedToStep(clamped)
+
+                if steppedRec > 0 {
+                    let mins = Int(ageSec / 60)
+                    let isStale5 = ageSec >= 5 * 60
+
+                    Section(header: Text("Recommended Bolus")) {
+                        Button {
+                            handleRecommendedBolusTap(rec: steppedRec, ageSec: ageSec)
+                        } label: {
+                            HStack {
+                                VStack(alignment: .leading, spacing: 4) {
+                                    Text("\(InsulinFormatter.shared.string(steppedRec))U")
+                                    Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago")
+                                        .font(.caption)
+                                        .foregroundColor(.secondary)
+                                }
+                                Spacer()
+                                Image(systemName: "arrow.up.circle.fill")
+                                    .font(.title2)
+                            }
+                            .padding(.vertical, 8)
+                        }
+                        .buttonStyle(PlainButtonStyle())
+                    }
+
+                    Section {
+                        let color: Color = isStale5 ? .red : .yellow
+                        Text("WARNING: New treatments may have occurred since the last recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago.")
+                            .font(.callout)
+                            .foregroundColor(color)
+                            .multilineTextAlignment(.leading)
+                    }
+
+                } else {
+                    EmptyView()
+                }
+
+            } else {
+                EmptyView()
+            }
+
+        } else {
+            EmptyView()
+        }
+    }
+
+    private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) {
+        let isStale5 = ageSec >= 5 * 60
+        let isStale12 = ageSec >= 12 * 60
+        if isStale12 { return }
+        if isStale5 {
+            let mins = Int(ageSec / 60)
+            alertMessage = "This recommended bolus was calculated \(mins) minutes ago. New treatments may have occurred since then. Proceed with caution."
+            alertType = .oldCalculationWarning
+            showAlert = true
+        } else {
+            applyRecommendedBolus(rec)
+        }
+    }
+
+    private func applyRecommendedBolus(_ rec: Double) {
+        let maxU = maxBolus.value.doubleValue(for: .internationalUnit())
+        let clamped = min(rec, maxU)
+        let stepped = roundedToStep(clamped)
+        bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped)
+    }
+
+    private func presentableMinutesFormat(timeInterval: TimeInterval) -> String {
+        let minutes = max(0, Int(timeInterval / 60))
+        var s = "\(minutes) minute"
+        if minutes == 0 || minutes > 1 { s += "s" }
+        return s
+    }
+
+    // MARK: - Send
+
     private func sendBolus() {
     private func sendBolus() {
         isLoading = true
         isLoading = true
-
         pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
         pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in
             DispatchQueue.main.async {
             DispatchQueue.main.async {
                 isLoading = false
                 isLoading = false
                 if success {
                 if success {
                     statusMessage = "Bolus command sent successfully."
                     statusMessage = "Bolus command sent successfully."
-                    LogManager.shared.log(category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U")
+                    LogManager.shared.log(
+                        category: .apns,
+                        message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U"
+                    )
                     bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
                     bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0)
                     alertType = .statusSuccess
                     alertType = .statusSuccess
                 } else {
                 } else {
                     statusMessage = errorMessage ?? "Failed to send bolus command."
                     statusMessage = errorMessage ?? "Failed to send bolus command."
-                    LogManager.shared.log(category: .apns, message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")")
+                    LogManager.shared.log(
+                        category: .apns,
+                        message: "sendBolusPushNotification failed with error: \(errorMessage ?? "unknown error")"
+                    )
                     alertType = .statusFailure
                     alertType = .statusFailure
                 }
                 }
                 showAlert = true
                 showAlert = true

+ 1 - 1
LoopFollow/Remote/TRC/MealView.swift

@@ -93,7 +93,7 @@ struct MealView: View {
                                 quantity: $bolusAmount,
                                 quantity: $bolusAmount,
                                 unit: .internationalUnit(),
                                 unit: .internationalUnit(),
                                 maxLength: 4,
                                 maxLength: 4,
-                                minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05),
+                                minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0),
                                 maxValue: maxBolus.value,
                                 maxValue: maxBolus.value,
                                 isFocused: $bolusFieldIsFocused,
                                 isFocused: $bolusFieldIsFocused,
                                 onValidationError: { message in
                                 onValidationError: { message in

+ 2 - 0
LoopFollow/Storage/Observable.swift

@@ -31,8 +31,10 @@ class Observable {
     var chartSettingsChanged = ObservableValue<Bool>(default: false)
     var chartSettingsChanged = ObservableValue<Bool>(default: false)
 
 
     var alertLastLoopTime = ObservableValue<TimeInterval?>(default: nil)
     var alertLastLoopTime = ObservableValue<TimeInterval?>(default: nil)
+    var previousAlertLastLoopTime = ObservableValue<TimeInterval?>(default: nil)
     var deviceRecBolus = ObservableValue<Double?>(default: nil)
     var deviceRecBolus = ObservableValue<Double?>(default: nil)
     var deviceBatteryLevel = ObservableValue<Double?>(default: nil)
     var deviceBatteryLevel = ObservableValue<Double?>(default: nil)
+    var enactedOrSuggested = ObservableValue<TimeInterval?>(default: nil)
 
 
     var settingsPath = ObservableValue<NavigationPath>(default: NavigationPath())
     var settingsPath = ObservableValue<NavigationPath>(default: NavigationPath())
 
 

+ 3 - 0
LoopFollow/Storage/Storage.swift

@@ -171,6 +171,9 @@ class Storage {
     var returnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
     var returnApnsKey = StorageValue<String>(key: "returnApnsKey", defaultValue: "")
     var returnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
     var returnKeyId = StorageValue<String>(key: "returnKeyId", defaultValue: "")
 
 
+    var bolusIncrement = SecureStorageValue<HKQuantity>(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05))
+    var bolusIncrementDetected = StorageValue<Bool>(key: "bolusIncrementDetected", defaultValue: false)
+
     static let shared = Storage()
     static let shared = Storage()
     private init() {}
     private init() {}
 }
 }