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 1/4] 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 2/4] 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 3/4] 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 4/4] 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