diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 1392e697e..2e8cac6f7 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -9,6 +9,7 @@ struct LoopAPNSCarbsView: View { @Environment(\.presentationMode) var presentationMode @ObservedObject private var quickPickMeals = QuickPickMealsManager.shared + @ObservedObject private var maxFutureCarbHours = Storage.shared.loopMaxFutureCarbHours @State private var carbsAmount = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var absorptionHours = 3 @State private var absorptionMinutes = 0 @@ -25,12 +26,15 @@ struct LoopAPNSCarbsView: View { private let otpPeriod: TimeInterval = 30 private let timeAdjustmentStepMinutes = 5 private let maxPastHours = 12 - private let maxFutureHours = 1 private let minAllowedAbsorptionTime = 0.5 private let maxAllowedAbsorptionTime = 8.0 private let lollipopStandardAbsorption: AbsorptionPreset = (0, 30) private let tacoStandardAbsorption: AbsorptionPreset = (3, 0) private let pizzaStandardAbsorption: AbsorptionPreset = (5, 0) + private var maxFutureHours: Int { + maxFutureCarbHours.value + } + private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @FocusState private var carbsFieldIsFocused: Bool @@ -516,7 +520,7 @@ struct LoopAPNSCarbsView: View { } if consumedDate > latestAcceptedDate { - alertMessage = "Time must be within the next \(maxFutureHours) hour" + alertMessage = "Time must be within the next \(maxFutureHours) \(maxFutureHours == 1 ? "hour" : "hours")" alertType = .error showAlert = true return diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift index fdf10c8f8..65d85caf8 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -19,7 +19,10 @@ struct LoopCarbEditSheet: View { @FocusState private var carbsFocused: Bool private let maxPastHours = 12 - private let maxFutureHours = 1 + private var maxFutureHours: Int { + Storage.shared.loopMaxFutureCarbHours.value + } + private let minAbsorptionHours = 0.5 private let maxAbsorptionHours = 8 diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index c3146108c..44a03cdae 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -10,6 +10,7 @@ struct RemoteSettingsView: View { @ObservedObject private var device = Storage.shared.device @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement @ObservedObject private var watchRemoteCommands = Storage.shared.watchRemoteCommands + @ObservedObject private var loopMaxFutureCarbHours = Storage.shared.loopMaxFutureCarbHours @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -90,6 +91,19 @@ struct RemoteSettingsView: View { } } + if viewModel.remoteType == .loopAPNS { + Section( + header: Text("Meal Settings"), + footer: Text("How far ahead carbs can be entered. Loop accepts up to 1 hr; use 4 hr if Loop is built with the future_carbs_4h customization.") + ) { + Picker("Max Future Carbs", selection: $loopMaxFutureCarbHours.value) { + ForEach(1 ... 4, id: \.self) { hours in + Text("\(hours) hr").tag(hours) + } + } + } + } + // MARK: - Guardrails Section (shown for both TRC and Loop) if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS { diff --git a/LoopFollow/Settings/ImportExport/ExportableSettings.swift b/LoopFollow/Settings/ImportExport/ExportableSettings.swift index 3571d8cfb..1150a6831 100644 --- a/LoopFollow/Settings/ImportExport/ExportableSettings.swift +++ b/LoopFollow/Settings/ImportExport/ExportableSettings.swift @@ -242,6 +242,7 @@ struct RemoteSettingsExport: Codable { let mealWithFatProtein: Bool let productionEnvironment: Bool let loopAPNSQrCodeURL: String + let loopMaxFutureCarbHours: Int? let device: String static func fromCurrentStorage() -> RemoteSettingsExport { @@ -262,6 +263,7 @@ struct RemoteSettingsExport: Codable { mealWithFatProtein: storage.mealWithFatProtein.value, productionEnvironment: storage.productionEnvironment.value, loopAPNSQrCodeURL: storage.loopAPNSQrCodeURL.value, + loopMaxFutureCarbHours: storage.loopMaxFutureCarbHours.value, device: storage.device.value ) } @@ -283,6 +285,9 @@ struct RemoteSettingsExport: Codable { storage.mealWithFatProtein.value = mealWithFatProtein storage.productionEnvironment.value = productionEnvironment storage.loopAPNSQrCodeURL.value = loopAPNSQrCodeURL + if let loopMaxFutureCarbHours { + storage.loopMaxFutureCarbHours.value = loopMaxFutureCarbHours + } // Set device temporarily from import (will be overridden by Nightscout connection) if !device.isEmpty { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4f533740a..2d294fc50 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -257,6 +257,7 @@ class Storage { var treatmentsPosition = StorageValue(key: "treatmentsPosition", defaultValue: .menu) var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") + var loopMaxFutureCarbHours = StorageValue(key: "loopMaxFutureCarbHours", defaultValue: 1) var bolusIncrement = SecureStorageValue(key: "bolusIncrement", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0.05)) var bolusIncrementDetected = StorageValue(key: "bolusIncrementDetected", defaultValue: false) diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index b7a28f480..053eabee3 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -45,9 +45,9 @@ struct LoopCarbTreatment: Codable, Equatable { absorptionMinutes.map { $0 / 60 } } - func isWithinEditWindow(now: Date = Date()) -> Bool { + func isWithinEditWindow(now: Date = Date(), maxFutureHours: Int = Storage.shared.loopMaxFutureCarbHours.value) -> Bool { let age = now.timeIntervalSince1970 - date - return age <= Self.editWindow && age >= -3600 + return age <= Self.editWindow && age >= -TimeInterval(maxFutureHours) * 3600 } /// Loop APNS targets a Loop device, whatever commands that build advertises. diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index c2a91c208..f6c3042cb 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -687,7 +687,9 @@ struct TreatmentDetailView: View { } private func loopRemoteActionsFooter(withinWindow: Bool) -> Text? { - withinWindow ? nil : Text("Carb entries can be changed remotely for 23 hours.") + guard !withinWindow else { return nil } + let futureHours = Storage.shared.loopMaxFutureCarbHours.value + return Text("Carb entries can be changed remotely up to 23 hours after and \(futureHours) \(futureHours == 1 ? "hour" : "hours") before their time.") } @ViewBuilder diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift index 9768dbcc2..4ea559bf6 100644 --- a/Tests/LoopCarbTreatmentTests.swift +++ b/Tests/LoopCarbTreatmentTests.swift @@ -39,16 +39,18 @@ struct LoopCarbTreatmentTests { #expect(LoopCarbTreatment(nightscoutEntry: entry(carbs: 0), date: 0) == nil) } - @Test("edit window is 23 h back and 1 h ahead") + @Test("edit window is 23 h back and the max future carbs setting ahead") func window() { let now = Date(timeIntervalSince1970: 1_700_000_000) func carb(offsetHours: Double) -> LoopCarbTreatment? { LoopCarbTreatment(nightscoutEntry: entry(), date: now.timeIntervalSince1970 + offsetHours * 3600) } - #expect(carb(offsetHours: -22)?.isWithinEditWindow(now: now) == true) - #expect(carb(offsetHours: -24)?.isWithinEditWindow(now: now) == false) - #expect(carb(offsetHours: 0.5)?.isWithinEditWindow(now: now) == true) - #expect(carb(offsetHours: 2)?.isWithinEditWindow(now: now) == false) + #expect(carb(offsetHours: -22)?.isWithinEditWindow(now: now, maxFutureHours: 1) == true) + #expect(carb(offsetHours: -24)?.isWithinEditWindow(now: now, maxFutureHours: 1) == false) + #expect(carb(offsetHours: 0.5)?.isWithinEditWindow(now: now, maxFutureHours: 1) == true) + #expect(carb(offsetHours: 2)?.isWithinEditWindow(now: now, maxFutureHours: 1) == false) + #expect(carb(offsetHours: 3.8)?.isWithinEditWindow(now: now, maxFutureHours: 4) == true) + #expect(carb(offsetHours: 4.5)?.isWithinEditWindow(now: now, maxFutureHours: 4) == false) } @Test("actions need Loop APNS, a Loop device and both commands advertised")