From 11977f9d220373b017482c29a7d855312c806537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 4 Oct 2025 10:59:02 +0200 Subject: [PATCH 01/14] Recommended bolus for Trio --- .../Nightscout/DeviceStatusOpenAPS.swift | 1 + LoopFollow/Remote/TRC/BolusView.swift | 138 +++++++++++++++--- LoopFollow/Storage/Observable.swift | 1 + 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 3ad58ae6e..d8d2bab4d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -45,6 +45,7 @@ extension MainViewController { updatedTime = parsedTime let formattedTime = Localizer.formatTimestampToLocalString(parsedTime) infoManager.updateInfoData(type: .updated, value: formattedTime) + Observable.shared.enactedOrSuggested.value = updatedTime } // ISF diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index a7af74142..8d7071f72 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -7,36 +7,45 @@ import SwiftUI struct BolusView: View { @Environment(\.presentationMode) private var presentationMode + @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) - private let pushNotificationManager = PushNotificationManager() @ObservedObject private var maxBolus = Storage.shared.maxBolus - @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 alertType: AlertType? = nil @State private var alertMessage: String? = nil @State private var isLoading = false @State private var statusMessage: String? = nil + private let pushNotificationManager = PushNotificationManager() + private let minDeliverableU: Double = 0.05 // hides negative/zero/tiny recs + enum AlertType { case confirmBolus case statusSuccess case statusFailure case validation + case oldCalculationWarning } var body: some View { NavigationView { - VStack { + // Updates once per second so the "X minutes ago" label stays fresh + TimelineView(.periodic(from: .now, by: 1)) { context in Form { + recommendedBlocks(now: context.date) + Section { HKQuantityInputView( label: "Bolus Amount", quantity: $bolusAmount, unit: .internationalUnit(), maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05), + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: minDeliverableU), maxValue: maxBolus.value, isFocused: $bolusFieldIsFocused, onValidationError: { message in @@ -52,7 +61,7 @@ struct BolusView: View { action: { bolusFieldIsFocused = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if bolusAmount.doubleValue(for: HKUnit.internationalUnit()) > 0.0 { + if bolusAmount.doubleValue(for: .internationalUnit()) > 0.0 { alertType = .confirmBolus showAlert = true } @@ -69,12 +78,10 @@ struct BolusView: View { case .confirmBolus: return Alert( 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 \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.2f") U?"), primaryButton: .default(Text("Confirm"), action: { authenticateUser { success in - if success { - sendBolus() - } + if success { sendBolus() } } }), secondaryButton: .cancel() @@ -99,6 +106,17 @@ struct BolusView: View { message: Text(alertMessage ?? "Invalid input."), 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, rec >= minDeliverableU { + applyRecommendedBolus(rec) + } + }, + secondaryButton: .cancel() + ) case .none: return Alert(title: Text("Unknown Alert")) } @@ -106,20 +124,103 @@ struct BolusView: View { } } + // MARK: - Recommended bolus UI + + @ViewBuilder + private func recommendedBlocks(now: Date) -> some View { + if let rec = deviceRecBolus.value, + rec >= minDeliverableU, // hides negative/zero/smalls + let t = enactedOrSuggested.value + { + let ageSec = max(0, now.timeIntervalSince1970 - t) + if ageSec < 12 * 60 { + let mins = Int(ageSec / 60) + let isStale5 = ageSec >= 5 * 60 + + Section(header: Text("Recommended Bolus")) { + Button { + handleRecommendedBolusTap(rec: rec, ageSec: ageSec) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(String(format: "%.2f", rec))U") + .font(.headline) + 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() + } + } + + 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) { + guard rec >= minDeliverableU else { return } + let clamped = min(rec, maxBolus.value.doubleValue(for: .internationalUnit())) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: clamped) + } + + 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() { isLoading = true - pushNotificationManager.sendBolusPushNotification(bolusAmount: bolusAmount) { success, errorMessage in DispatchQueue.main.async { isLoading = false if success { 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: \(bolusAmount.doubleValue(for: .internationalUnit())) U" + ) bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) alertType = .statusSuccess } else { 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 } showAlert = true @@ -130,25 +231,18 @@ struct BolusView: View { private func authenticateUser(completion: @escaping (Bool) -> Void) { let context = LAContext() var error: NSError? - let reason = "Confirm your identity to send bolus." if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - completion(success) - } + DispatchQueue.main.async { completion(success) } } } else if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in - DispatchQueue.main.async { - completion(success) - } + DispatchQueue.main.async { completion(success) } } } else { - DispatchQueue.main.async { - completion(false) - } + DispatchQueue.main.async { completion(false) } } } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index b20615d5f..c80f9d3ce 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -32,6 +32,7 @@ class Observable { var alertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) + var enactedOrSuggested = ObservableValue(default: nil) var settingsPath = ObservableValue(default: NavigationPath()) From e8756f15382740bd3f9e09130c6c1f69fbc127a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 17 Oct 2025 18:38:24 +0200 Subject: [PATCH 02/14] wip --- .../Controllers/Nightscout/DeviceStatus.swift | 8 +++ .../Nightscout/DeviceStatusOpenAPS.swift | 8 +++ .../Remote/Settings/RemoteSettingsView.swift | 32 ++++++++- LoopFollow/Remote/TRC/BolusView.swift | 65 +++++++++++++++---- LoopFollow/Storage/Storage.swift | 3 + 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index db64980bd..991bf5f55 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -3,6 +3,7 @@ import Charts import Foundation +import HealthKit import UIKit extension MainViewController { @@ -96,6 +97,13 @@ extension MainViewController { .withDashSeparatorInDate, .withColonSeparatorInTime] if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { + if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 { + Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement) + Storage.shared.bolusIncrementDetected.value = true + } else { + Storage.shared.bolusIncrementDetected.value = false + } + if let lastPumpTime = formatter.date(from: (lastPumpRecord["clock"] as! String))?.timeIntervalSince1970 { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 49f68b9d7..4b33ca6ae 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -123,6 +123,14 @@ extension MainViewController { 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 if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 1214dcbcf..ecc1ce30b 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @ObservedObject private var device = Storage.shared.device + @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -109,6 +110,29 @@ struct RemoteSettingsView: View { 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 if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS { @@ -160,9 +184,12 @@ struct RemoteSettingsView: View { Section(header: Text("Debug / Info")) { 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("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") .foregroundColor(.red) } + if Storage.shared.bolusIncrementDetected.value { + Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") + } } if viewModel.areTeamIdsDifferent { diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 1485ae6a9..b15f6ad56 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -10,6 +10,7 @@ struct BolusView: View { @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) @ObservedObject private var maxBolus = Storage.shared.maxBolus + @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested @@ -22,7 +23,6 @@ struct BolusView: View { @State private var statusMessage: String? = nil private let pushNotificationManager = PushNotificationManager() - private let minDeliverableU: Double = 0.05 // hides negative/zero/tiny recs enum AlertType { case confirmBolus @@ -32,9 +32,34 @@ struct BolusView: View { 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), 6) + } + + private func roundedToStep(_ value: Double) -> Double { + guard stepU > 0 else { return value } + let stepped = (value / stepU).rounded() * stepU + let p = pow(10.0, Double(stepFractionDigits)) + return (stepped * p).rounded() / p + } + + // MARK: - View + var body: some View { NavigationView { - // Updates once per second so the "X minutes ago" label stays fresh TimelineView(.periodic(from: .now, by: 1)) { context in Form { recommendedBlocks(now: context.date) @@ -42,10 +67,20 @@ struct BolusView: View { Section { HKQuantityInputView( label: "Bolus Amount", - quantity: $bolusAmount, + quantity: Binding( + get: { bolusAmount }, + set: { q in + let v = q.doubleValue(for: .internationalUnit()) + let minU = stepU + let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = min(max(v, minU), maxU) + let stepped = roundedToStep(clamped) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped) + } + ), unit: .internationalUnit(), - maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: minDeliverableU), + maxLength: 6, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: stepU), maxValue: maxBolus.value, isFocused: $bolusFieldIsFocused, onValidationError: { message in @@ -78,7 +113,9 @@ struct BolusView: View { case .confirmBolus: return Alert( title: Text("Confirm Bolus"), - message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.2f") U?"), + message: Text( + "Are you sure you want to send \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.\(stepFractionDigits)f") U?" + ), primaryButton: .default(Text("Confirm"), action: { AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in if case .success = result { @@ -113,7 +150,7 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value, rec >= minDeliverableU { + if let rec = deviceRecBolus.value, rec >= stepU { applyRecommendedBolus(rec) } }, @@ -131,7 +168,7 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { if let rec = deviceRecBolus.value, - rec >= minDeliverableU, // hides negative/zero/smalls + rec >= stepU, let t = enactedOrSuggested.value { let ageSec = max(0, now.timeIntervalSince1970 - t) @@ -145,7 +182,7 @@ struct BolusView: View { } label: { HStack { VStack(alignment: .leading, spacing: 4) { - Text("\(String(format: "%.2f", rec))U") + Text("\(String(format: "%.\(stepFractionDigits)f", rec))U") .font(.headline) Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago") .font(.caption) @@ -190,9 +227,11 @@ struct BolusView: View { } private func applyRecommendedBolus(_ rec: Double) { - guard rec >= minDeliverableU else { return } - let clamped = min(rec, maxBolus.value.doubleValue(for: .internationalUnit())) - bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: clamped) + guard rec >= stepU else { return } + 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 { @@ -213,7 +252,7 @@ struct BolusView: View { statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, - message: "sendBolusPushNotification succeeded - Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U" + message: "sendBolusPushNotification succeeded - Bolus: \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.\(stepFractionDigits)f") U" ) bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) alertType = .statusSuccess diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 64b82bb18..42e9ede2a 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -170,6 +170,9 @@ class Storage { var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) + var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) + static let shared = Storage() private init() {} } From ba81a0b06472876bf2b34f179217b59f86d5d592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 19 Oct 2025 18:45:11 +0200 Subject: [PATCH 03/14] WIP --- LoopFollow.xcodeproj/project.pbxproj | 12 ++++- .../Controllers/Nightscout/DeviceStatus.swift | 8 +++ .../Nightscout/DeviceStatusOpenAPS.swift | 8 +++ LoopFollow/Extensions/HKUnit+Extensions.swift | 2 +- LoopFollow/Helpers/InsulinFormatter.swift | 24 +++++++++ .../Helpers/InsulinPrecisionManager.swift | 34 +++++++++++++ .../Helpers/Views/HKQuantityInputView.swift | 3 +- .../Remote/Settings/RemoteSettingsView.swift | 32 +++++++++++- LoopFollow/Remote/TRC/BolusView.swift | 49 ++++++++++++++----- LoopFollow/Storage/Storage.swift | 3 ++ 10 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 LoopFollow/Helpers/InsulinFormatter.swift create mode 100644 LoopFollow/Helpers/InsulinPrecisionManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 2871a59de..6e9ab1253 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -16,11 +16,13 @@ 656F8C102E49F36F0008DC1D /* QRCodeDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C0F2E49F36F0008DC1D /* QRCodeDisplayView.swift */; }; 656F8C122E49F3780008DC1D /* QRCodeGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F8C112E49F3780008DC1D /* QRCodeGenerator.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 */; }; + 65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; }; 65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; + DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */; }; + DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */; }; DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; }; DD0650EB2DCE8385004D3B41 /* LowBGCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650EA2DCE8385004D3B41 /* LowBGCondition.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 = ""; }; 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeGenerator.swift; sourceTree = ""; }; 656F8C132E49F3D20008DC1D /* RemoteCommandSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandSettings.swift; sourceTree = ""; }; - 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; + 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = ""; }; 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = ""; }; 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 = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; + DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinPrecisionManager.swift; sourceTree = ""; }; + DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinFormatter.swift; sourceTree = ""; }; DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmBGSection.swift; sourceTree = ""; }; DD0650EA2DCE8385004D3B41 /* LowBGCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LowBGCondition.swift; sourceTree = ""; }; DD0650EC2DCE9371004D3B41 /* HighBgAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighBgAlarmEditor.swift; sourceTree = ""; }; @@ -1505,6 +1509,8 @@ FCC688542489367300A0279D /* Helpers */ = { isa = PBXGroup; children = ( + DD026E5A2EA2C9C300A39CB5 /* InsulinFormatter.swift */, + DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */, 656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */, DD4A407D2E6AFEE6007B318B /* AuthService.swift */, DD1D52B82E1EB5DC00432050 /* TabPosition.swift */, @@ -1971,6 +1977,7 @@ DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, + DD026E592EA2C8A200A39CB5 /* InsulinPrecisionManager.swift in Sources */, DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */, DDC7E5162DBCFA7F00EB1127 /* SnoozerView.swift in Sources */, FCFEECA2248857A600402A7F /* SettingsViewController.swift in Sources */, @@ -2085,6 +2092,7 @@ DD0C0C662C46E54C00DBADDF /* InfoDataSeparator.swift in Sources */, DD58171C2D299F940041FB98 /* BluetoothDevice.swift in Sources */, DD7E198A2ACDA62600DBD158 /* SensorStart.swift in Sources */, + DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */, DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */, diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index db64980bd..991bf5f55 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -3,6 +3,7 @@ import Charts import Foundation +import HealthKit import UIKit extension MainViewController { @@ -96,6 +97,13 @@ extension MainViewController { .withDashSeparatorInDate, .withColonSeparatorInTime] if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { + if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 { + Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement) + Storage.shared.bolusIncrementDetected.value = true + } else { + Storage.shared.bolusIncrementDetected.value = false + } + if let lastPumpTime = formatter.date(from: (lastPumpRecord["clock"] as! String))?.timeIntervalSince1970 { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 49f68b9d7..4b33ca6ae 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -123,6 +123,14 @@ extension MainViewController { 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 if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double { let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue) diff --git a/LoopFollow/Extensions/HKUnit+Extensions.swift b/LoopFollow/Extensions/HKUnit+Extensions.swift index 9eccbab84..62e2a390d 100644 --- a/LoopFollow/Extensions/HKUnit+Extensions.swift +++ b/LoopFollow/Extensions/HKUnit+Extensions.swift @@ -18,7 +18,7 @@ extension HKUnit { case .millimolesPerLiter: return 1 case .internationalUnit(): - return 3 + return InsulinPrecisionManager.shared.fractionDigits default: return 0 } diff --git a/LoopFollow/Helpers/InsulinFormatter.swift b/LoopFollow/Helpers/InsulinFormatter.swift new file mode 100644 index 000000000..bbd165078 --- /dev/null +++ b/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) + } +} diff --git a/LoopFollow/Helpers/InsulinPrecisionManager.swift b/LoopFollow/Helpers/InsulinPrecisionManager.swift new file mode 100644 index 000000000..583964f7c --- /dev/null +++ b/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() + + 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) + } +} diff --git a/LoopFollow/Helpers/Views/HKQuantityInputView.swift b/LoopFollow/Helpers/Views/HKQuantityInputView.swift index 4e0670da0..674a6da88 100644 --- a/LoopFollow/Helpers/Views/HKQuantityInputView.swift +++ b/LoopFollow/Helpers/Views/HKQuantityInputView.swift @@ -13,9 +13,10 @@ struct HKQuantityInputView: View { var minValue: HKQuantity var maxValue: HKQuantity @FocusState.Binding var isFocused: Bool - var onValidationError: (String) -> Void + @ObservedObject private var insulinPrecision = InsulinPrecisionManager.shared + var body: some View { HStack { Text(label) diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 1214dcbcf..ecc1ce30b 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @ObservedObject private var device = Storage.shared.device + @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -109,6 +110,29 @@ struct RemoteSettingsView: View { 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 if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS { @@ -160,9 +184,12 @@ struct RemoteSettingsView: View { Section(header: Text("Debug / Info")) { 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("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") .foregroundColor(.red) } + if Storage.shared.bolusIncrementDetected.value { + Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") + } } if viewModel.areTeamIdsDifferent { diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 1485ae6a9..0078df131 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -10,6 +10,7 @@ struct BolusView: View { @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) @ObservedObject private var maxBolus = Storage.shared.maxBolus + @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested @@ -22,7 +23,6 @@ struct BolusView: View { @State private var statusMessage: String? = nil private let pushNotificationManager = PushNotificationManager() - private let minDeliverableU: Double = 0.05 // hides negative/zero/tiny recs enum AlertType { case confirmBolus @@ -32,9 +32,34 @@ struct BolusView: View { 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 stepped = (value / stepU).rounded() * stepU + let p = pow(10.0, Double(stepFractionDigits)) + return (stepped * p).rounded() / p + } + + // MARK: - View + var body: some View { NavigationView { - // Updates once per second so the "X minutes ago" label stays fresh TimelineView(.periodic(from: .now, by: 1)) { context in Form { recommendedBlocks(now: context.date) @@ -44,8 +69,8 @@ struct BolusView: View { label: "Bolus Amount", quantity: $bolusAmount, unit: .internationalUnit(), - maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: minDeliverableU), + maxLength: 5, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: stepU), maxValue: maxBolus.value, isFocused: $bolusFieldIsFocused, onValidationError: { message in @@ -78,7 +103,7 @@ struct BolusView: View { case .confirmBolus: return Alert( title: Text("Confirm Bolus"), - message: Text("Are you sure you want to send \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.2f") U?"), + primaryButton: .default(Text("Confirm"), action: { AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in if case .success = result { @@ -113,7 +138,7 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value, rec >= minDeliverableU { + if let rec = deviceRecBolus.value, rec >= stepU { applyRecommendedBolus(rec) } }, @@ -131,7 +156,7 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { if let rec = deviceRecBolus.value, - rec >= minDeliverableU, // hides negative/zero/smalls + rec >= stepU, let t = enactedOrSuggested.value { let ageSec = max(0, now.timeIntervalSince1970 - t) @@ -145,7 +170,7 @@ struct BolusView: View { } label: { HStack { VStack(alignment: .leading, spacing: 4) { - Text("\(String(format: "%.2f", rec))U") + Text("\(String(format: "%.\(stepFractionDigits)f", rec))U") .font(.headline) Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago") .font(.caption) @@ -190,9 +215,11 @@ struct BolusView: View { } private func applyRecommendedBolus(_ rec: Double) { - guard rec >= minDeliverableU else { return } - let clamped = min(rec, maxBolus.value.doubleValue(for: .internationalUnit())) - bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: clamped) + guard rec >= stepU else { return } + 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 { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 64b82bb18..42e9ede2a 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -170,6 +170,9 @@ class Storage { var returnApnsKey = StorageValue(key: "returnApnsKey", defaultValue: "") var returnKeyId = StorageValue(key: "returnKeyId", defaultValue: "") + var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) + var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) + static let shared = Storage() private init() {} } From 120fdcc1dda963f0e4bf18dc404398cf1258240a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 19 Oct 2025 19:36:51 +0200 Subject: [PATCH 04/14] adjustments --- LoopFollow/Remote/TRC/BolusView.swift | 21 +++++---------------- LoopFollow/Remote/TRC/MealView.swift | 2 +- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index e5440c0f2..f6f504ef6 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -67,20 +67,10 @@ struct BolusView: View { Section { HKQuantityInputView( label: "Bolus Amount", - quantity: Binding( - get: { bolusAmount }, - set: { q in - let v = q.doubleValue(for: .internationalUnit()) - let minU = stepU - let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) - let clamped = min(max(v, minU), maxU) - let stepped = roundedToStep(clamped) - bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: stepped) - } - ), + quantity: $bolusAmount, unit: .internationalUnit(), maxLength: 5, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: stepU), + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), maxValue: maxBolus.value, isFocused: $bolusFieldIsFocused, onValidationError: { message in @@ -113,7 +103,7 @@ struct BolusView: View { case .confirmBolus: return Alert( title: Text("Confirm Bolus"), - + message: Text("Are you sure you want to send \(InsulinFormatter.shared.string(bolusAmount)) U?"), primaryButton: .default(Text("Confirm"), action: { AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in if case .success = result { @@ -180,8 +170,7 @@ struct BolusView: View { } label: { HStack { VStack(alignment: .leading, spacing: 4) { - Text("\(String(format: "%.\(stepFractionDigits)f", rec))U") - .font(.headline) + Text("\(InsulinFormatter.shared.string(rec))U") Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago") .font(.caption) .foregroundColor(.secondary) @@ -250,7 +239,7 @@ struct BolusView: View { statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, - message: "sendBolusPushNotification succeeded - Bolus: \(bolusAmount.doubleValue(for: .internationalUnit()), specifier: "%.\(stepFractionDigits)f") U" + message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U" ) bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) alertType = .statusSuccess diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index ef9b096ed..517db1811 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -93,7 +93,7 @@ struct MealView: View { quantity: $bolusAmount, unit: .internationalUnit(), maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05), + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), maxValue: maxBolus.value, isFocused: $bolusFieldIsFocused, onValidationError: { message in From a4a8f333d2fae121de718f742774ceee92382aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 20 Nov 2025 12:52:31 +0100 Subject: [PATCH 05/14] Bolusincrement for Loop dash and refactoring --- .../Controllers/Nightscout/DeviceStatus.swift | 51 +++-- .../Nightscout/DeviceStatusLoop.swift | 180 +++++++++--------- .../Nightscout/DeviceStatusOpenAPS.swift | 21 +- LoopFollow/Storage/Observable.swift | 1 + 4 files changed, 123 insertions(+), 130 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 1ae13dd6e..c327c636c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -96,15 +96,28 @@ extension MainViewController { .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] + + Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value + if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { 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 lastPumpTime = formatter.date(from: (lastPumpRecord["clock"] as! String))?.timeIntervalSince1970 { + 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 { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") @@ -112,27 +125,27 @@ extension MainViewController { latestPumpVolume = 50.0 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() } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index d1faf273c..fe10b62b9 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -14,112 +14,110 @@ extension MainViewController { 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() } - 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 = "↻" } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 4b33ca6ae..0d7bc62c6 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -18,25 +18,6 @@ extension MainViewController { 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? if let timestamp = enactedOrSuggested["timestamp"] as? String, @@ -235,7 +216,7 @@ extension MainViewController { if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date } - if tempBasalTime > lastBGTime, !wasEnacted { + if tempBasalTime > lastBGTime { LoopStatusLabel.text = "⏀" latestLoopStatusString = "⏀" } else { diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 8ba0ccb84..8a847dfcc 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -31,6 +31,7 @@ class Observable { var chartSettingsChanged = ObservableValue(default: false) var alertLastLoopTime = ObservableValue(default: nil) + var previousAlertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) var enactedOrSuggested = ObservableValue(default: nil) From e3d0dad169855e3958eb4ffd1c865774ecf7807e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 21 Nov 2025 15:38:31 +0100 Subject: [PATCH 06/14] 722 and 723 --- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index c327c636c..977ae862b 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -103,9 +103,13 @@ extension MainViewController { 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" { + + } else if let model = lastPumpRecord["model"] as? String, + ["Dash", "723", "722"].contains(model) + { Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: 0.05) Storage.shared.bolusIncrementDetected.value = true + } else { Storage.shared.bolusIncrementDetected.value = false } From 54445998b382e29382a27d02c67563465a81e52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 22 Nov 2025 10:27:10 +0100 Subject: [PATCH 07/14] Revert "722 and 723" This reverts commit e3d0dad169855e3958eb4ffd1c865774ecf7807e. --- LoopFollow/Controllers/Nightscout/DeviceStatus.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 977ae862b..c327c636c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -103,13 +103,9 @@ extension MainViewController { 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, - ["Dash", "723", "722"].contains(model) - { + } 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 } From c2bc1f7e8e54d3cd09ce6e908dac6c8f7ca4e0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 23 Nov 2025 21:11:13 +0100 Subject: [PATCH 08/14] Round up the bolus value --- LoopFollow/Helpers/TextFieldWithToolBar.swift | 2 +- LoopFollow/Remote/TRC/BolusView.swift | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Helpers/TextFieldWithToolBar.swift b/LoopFollow/Helpers/TextFieldWithToolBar.swift index 8e57c781c..d65fe3dee 100644 --- a/LoopFollow/Helpers/TextFieldWithToolBar.swift +++ b/LoopFollow/Helpers/TextFieldWithToolBar.swift @@ -206,7 +206,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable { let value = quantity.doubleValue(for: unit) let formatter = NumberFormatter() formatter.minimumFractionDigits = unit.preferredFractionDigits - formatter.maximumFractionDigits = unit.preferredFractionDigits + formatter.maximumFractionDigits = max(unit.preferredFractionDigits, 3) formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "" } diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index f6f504ef6..548f73bea 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -51,7 +51,9 @@ struct BolusView: View { private func roundedToStep(_ value: Double) -> Double { guard stepU > 0 else { return value } - let stepped = (value / stepU).rounded() * stepU + + let stepped = (value / stepU).rounded(.up) * stepU + let p = pow(10.0, Double(stepFractionDigits)) return (stepped * p).rounded() / p } @@ -87,6 +89,9 @@ struct BolusView: View { bolusFieldIsFocused = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if bolusAmount.doubleValue(for: .internationalUnit()) > 0.0 { + let steppedAmount = roundedToStep(self.bolusAmount.doubleValue(for: .internationalUnit())) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount) + alertType = .confirmBolus showAlert = true } @@ -138,7 +143,7 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value, rec >= stepU { + if let rec = deviceRecBolus.value { applyRecommendedBolus(rec) } }, @@ -156,7 +161,6 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { if let rec = deviceRecBolus.value, - rec >= stepU, let t = enactedOrSuggested.value { let ageSec = max(0, now.timeIntervalSince1970 - t) @@ -214,7 +218,6 @@ struct BolusView: View { } private func applyRecommendedBolus(_ rec: Double) { - guard rec >= stepU else { return } let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) let clamped = min(rec, maxU) let stepped = roundedToStep(clamped) From 54259ceaff3b5cfe1177ae18bda7e9badde47d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 8 Dec 2025 16:30:52 +0100 Subject: [PATCH 09/14] Rounding down --- LoopFollow/Remote/TRC/BolusView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 548f73bea..49029dddc 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -52,7 +52,7 @@ struct BolusView: View { private func roundedToStep(_ value: Double) -> Double { guard stepU > 0 else { return value } - let stepped = (value / stepU).rounded(.up) * stepU + let stepped = (value / stepU).rounded(.down) * stepU let p = pow(10.0, Double(stepFractionDigits)) return (stepped * p).rounded() / p From cf2d3ac677316da187f2c45f9e109dc500b06618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 8 Dec 2025 21:12:07 +0100 Subject: [PATCH 10/14] Hide recommended bolus if there is none --- LoopFollow/Remote/TRC/BolusView.swift | 62 ++++++++++++++++----------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 49029dddc..7c14a9e53 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -164,40 +164,52 @@ struct BolusView: View { let t = enactedOrSuggested.value { let ageSec = max(0, now.timeIntervalSince1970 - t) + if ageSec < 12 * 60 { - let mins = Int(ageSec / 60) - let isStale5 = ageSec >= 5 * 60 - - Section(header: Text("Recommended Bolus")) { - Button { - handleRecommendedBolusTap(rec: rec, ageSec: ageSec) - } label: { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("\(InsulinFormatter.shared.string(rec))U") - Text("Calculated \(mins) minute\(mins == 1 ? "" : "s") ago") - .font(.caption) - .foregroundColor(.secondary) + 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) } - Spacer() - Image(systemName: "arrow.up.circle.fill") - .font(.title2) + .padding(.vertical, 8) } - .padding(.vertical, 8) + .buttonStyle(PlainButtonStyle()) } - .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) + 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() } From 3b30ceb98c0401e31689e431aaef10986004ec0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 8 Dec 2025 22:17:00 +0100 Subject: [PATCH 11/14] roundedToStep with epsilon --- LoopFollow/Remote/TRC/BolusView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 7c14a9e53..f9106ac96 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -51,9 +51,10 @@ struct BolusView: View { private func roundedToStep(_ value: Double) -> Double { guard stepU > 0 else { return value } - - let stepped = (value / stepU).rounded(.down) * stepU - + + let epsilon: Double = 1e-10 + let stepped = ((value / stepU) + epsilon).rounded(.down) * stepU + let p = pow(10.0, Double(stepFractionDigits)) return (stepped * p).rounded() / p } From 29ad622982b3188496dc1cc5cb98ab169adea18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 11 Dec 2025 20:46:33 +0100 Subject: [PATCH 12/14] Linted code --- LoopFollow/Remote/TRC/BolusView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index f9106ac96..fc94f1c8a 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -51,10 +51,10 @@ struct BolusView: View { private func roundedToStep(_ value: Double) -> Double { guard stepU > 0 else { return value } - - let epsilon: Double = 1e-10 + + let epsilon = 1e-10 let stepped = ((value / stepU) + epsilon).rounded(.down) * stepU - + let p = pow(10.0, Double(stepFractionDigits)) return (stepped * p).rounded() / p } From 17777d89b0d1b1985cb48ec5ac0192c9fc7d8de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 12 Dec 2025 19:31:09 +0100 Subject: [PATCH 13/14] Ensure stepped bolus > 0 before sending --- LoopFollow/Remote/TRC/BolusView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index fc94f1c8a..bb70a7744 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -66,7 +66,7 @@ struct BolusView: View { TimelineView(.periodic(from: .now, by: 1)) { context in Form { recommendedBlocks(now: context.date) - + Section { HKQuantityInputView( label: "Bolus Amount", @@ -81,7 +81,7 @@ struct BolusView: View { } ) } - + LoadingButtonView( buttonText: "Send Bolus", progressText: "Sending Bolus...", @@ -89,10 +89,11 @@ struct BolusView: View { action: { bolusFieldIsFocused = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if bolusAmount.doubleValue(for: .internationalUnit()) > 0.0 { - let steppedAmount = roundedToStep(self.bolusAmount.doubleValue(for: .internationalUnit())) + let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit()) + let steppedAmount = roundedToStep(rawValue) + + if steppedAmount > 0 { bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount) - alertType = .confirmBolus showAlert = true } From e2b1af00dca1ffd7094cdf31de677441dff75c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 12 Dec 2025 19:32:30 +0100 Subject: [PATCH 14/14] Linting --- LoopFollow/Remote/TRC/BolusView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index bb70a7744..0a70f86b9 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -66,7 +66,7 @@ struct BolusView: View { TimelineView(.periodic(from: .now, by: 1)) { context in Form { recommendedBlocks(now: context.date) - + Section { HKQuantityInputView( label: "Bolus Amount", @@ -81,7 +81,7 @@ struct BolusView: View { } ) } - + LoadingButtonView( buttonText: "Send Bolus", progressText: "Sending Bolus...", @@ -91,7 +91,7 @@ struct BolusView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { let rawValue = self.bolusAmount.doubleValue(for: .internationalUnit()) let steppedAmount = roundedToStep(rawValue) - + if steppedAmount > 0 { bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: steppedAmount) alertType = .confirmBolus