From 1a434f6ea8252d532f70478cb4d8baab95e210e5 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:35:58 -0400 Subject: [PATCH 1/9] Open the watch bolus on the amount, start at the recommendation, and fit the meal and bolus buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bolus screen opens on the amount instead of a list that, without Quick-Pick boluses or a recent recommendation, held only Other Amount. Loop / Trio's recommendation and the Quick-Pick boluses move behind a Presets button under Continue, shown only when there are any, with the Old Calculation Warning as before. While the recommendation is under 5 minutes old the amount starts there, with an icon line showing the amount and its age. It is filled in once, so coming back from the confirmation keeps what was set. The Max line is gone, since the crown and + stop at the maximum, and − and + click like a crown detent. The meal and bolus buttons use a compact capsule whose height scales with the watch, so the amount and two buttons fit without scrolling; the system buttons pushed Log off the bottom of the meal screen. On the meal screen, Time and Log are both centered, so they line up. --- LoopFollowWatch/ContentView.swift | 6 +- LoopFollowWatch/Remote/BolusEntryView.swift | 127 ++++++++++++++------ LoopFollowWatch/Remote/MealEntryView.swift | 8 +- 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index f8abc2df1..073a59fcc 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -9,7 +9,7 @@ enum WatchRoute: Hashable { case treatments case meal case bolus - case bolusAmount + case bolusPresets case overrides case tempTargets case confirm(CommandRequest) @@ -75,9 +75,9 @@ struct ContentView: View { case .meal: MealEntryView(remote: remote) case .bolus: - BolusPickerView(remote: remote) - case .bolusAmount: BolusAmountView(remote: remote) + case .bolusPresets: + BolusPresetsView(remote: remote) case .overrides: PresetListView(kind: .override, state: state, presets: remote.overridePresets) case .tempTargets: diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 7129b1d67..b2d652209 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -2,38 +2,49 @@ // BolusEntryView.swift import SwiftUI +import WatchKit -/// Pick an amount: the AID's recommendation, the phone's Quick-Pick boluses, or -/// any amount with the Digital Crown. Every choice still needs the crown confirmation. -struct BolusPickerView: View { +/// The AID's recommendation and the phone's Quick-Pick boluses, opened from the +/// amount screen. Every choice still needs the crown confirmation. +struct BolusPresetsView: View { let remote: WatchRemoteConfig @EnvironmentObject private var navigator: WatchNavigator @State private var staleRecommendation: Double? /// Same cut-offs as the phone's bolus screens. - private let recommendationMaxAge: TimeInterval = 12 * 60 - private let recommendationWarnAge: TimeInterval = 5 * 60 + private static let recommendationMaxAge: TimeInterval = 12 * 60 + static let recommendationWarnAge: TimeInterval = 5 * 60 - private var step: Double { BolusAmountView.step(remote) } + static func quickPicks(_ remote: WatchRemoteConfig) -> [Double] { + remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus } + } + + /// A recommendation recent enough to offer, rounded down to the watch's step. + static func freshRecommendation(_ remote: WatchRemoteConfig) -> (units: Double, age: TimeInterval)? { + guard let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt else { return nil } + let age = Date().timeIntervalSince(at) + let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)) + guard age < recommendationMaxAge, stepped > 0 else { return nil } + return (stepped, age) + } + + static func hasPresets(_ remote: WatchRemoteConfig) -> Bool { + freshRecommendation(remote) != nil || !quickPicks(remote).isEmpty + } var body: some View { List { recommendation - ForEach(remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus }, id: \.self) { units in + ForEach(Self.quickPicks(remote), id: \.self) { units in Button { confirm(units) } label: { Text("\(WatchFormat.insulin(units)) U") } } - Button { - navigator.push(.bolusAmount) - } label: { - Text("Other Amount") - } } - .navigationTitle("Bolus") + .navigationTitle("Presets") .alert( "Old Calculation Warning", isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) @@ -49,26 +60,21 @@ struct BolusPickerView: View { @ViewBuilder private var recommendation: some View { - if let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt { - let age = Date().timeIntervalSince(at) - if age < recommendationMaxAge { - let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: step) - Section { - Button { - if age >= recommendationWarnAge { - staleRecommendation = stepped - } else { - confirm(stepped) - } - } label: { - VStack(alignment: .leading, spacing: 2) { - Text("Recommended \(WatchFormat.insulin(stepped)) U") - Text("Calculated \(Int(age / 60)) min ago") - .font(.caption2) - .foregroundStyle(age >= recommendationWarnAge ? .red : .secondary) - } + if let rec = Self.freshRecommendation(remote) { + Section { + Button { + if rec.age >= Self.recommendationWarnAge { + staleRecommendation = rec.units + } else { + confirm(rec.units) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(rec.units)) U") + Text("Calculated \(Int(rec.age / 60)) min ago") + .font(.caption2) + .foregroundStyle(rec.age >= Self.recommendationWarnAge ? .red : .secondary) } - .disabled(stepped <= 0) } } } @@ -79,12 +85,17 @@ struct BolusPickerView: View { } } -/// Any amount, set with the Digital Crown. +/// Any amount, set with the Digital Crown or − and +, starting at the AID's +/// recommendation while it is under 5 minutes old; presets one tap away when +/// the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig @EnvironmentObject private var navigator: WatchNavigator @State private var units = 0.0 + /// Seeded once, so coming back from the confirmation keeps the user's amount. + @State private var seeded = false + @State private var seededRecommendation: (units: Double, age: TimeInterval)? @FocusState private var amountFocused: Bool var body: some View { @@ -92,19 +103,36 @@ struct BolusAmountView: View { VStack(spacing: 8) { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) - Text("Max \(WatchFormat.insulin(remote.maxBolus)) U") - .font(.footnote) - .foregroundStyle(.secondary) + if let rec = seededRecommendation { + Label("\(WatchFormat.insulin(rec.units)) U, \(Int(rec.age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityLabel("Recommended \(WatchFormat.insulin(rec.units)) U, \(Int(rec.age / 60)) min ago") + } Button("Continue") { navigator.push(.confirm(Self.request(units))) } - .buttonStyle(.borderedProminent) + .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(units <= 0) + if BolusPresetsView.hasPresets(remote) { + Button("Presets") { + navigator.push(.bolusPresets) + } + .buttonStyle(CompactButtonStyle()) + } } } .navigationTitle("Bolus") - .onAppear { amountFocused = true } + .onAppear { + amountFocused = true + guard !seeded else { return } + seeded = true + if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { + units = rec.units + seededRecommendation = rec + } + } } static func step(_ remote: WatchRemoteConfig) -> Double { @@ -174,6 +202,7 @@ struct AmountField: View { private func nudge(_ symbol: String, by delta: Double) -> some View { Button { steps.wrappedValue += delta + WKInterfaceDevice.current().play(.click) } label: { Image(systemName: symbol) .font(.footnote.weight(.semibold)) @@ -184,3 +213,25 @@ struct AmountField: View { .foregroundStyle(.tint) } } + +/// A full-width capsule button shorter than the system's, scaled to the screen, +/// so an amount and two buttons fit without scrolling on every watch size. +struct CompactButtonStyle: ButtonStyle { + var prominent = false + + @Environment(\.isEnabled) private var isEnabled + + /// About 34 pt on a 40 mm watch up to about 43 pt on the largest. + private static let height = min(max(WKInterfaceDevice.current().screenBounds.height * 0.17, 32), 44) + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .lineLimit(1) + .minimumScaleFactor(0.7) + .foregroundStyle(prominent ? AnyShapeStyle(Color.white) : AnyShapeStyle(.primary)) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: Self.height) + .background(Capsule().fill(prominent ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.gray.opacity(0.3)))) + .opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4) + } +} diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift index 504e06d0a..bc6529003 100644 --- a/LoopFollowWatch/Remote/MealEntryView.swift +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -57,18 +57,16 @@ struct MealEntryView: View { } label: { Label("Time", systemImage: "clock") .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity) + .buttonStyle(CompactButtonStyle()) } Button { navigator.push(.confirm(request(carbs: carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: offsetMinutes))) } label: { Text(logTitle) - .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(carbs <= 0 && fat <= 0 && protein <= 0) } @@ -91,7 +89,7 @@ struct MealEntryView: View { Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity) + .buttonStyle(CompactButtonStyle()) } } } From 33e44a21ffbfa5ee4bead585af65136995f9bcc9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:45:24 -0400 Subject: [PATCH 2/9] Warn when the watch's starting recommendation goes stale, and drop it from Presets while fresh --- LoopFollowWatch/Remote/BolusEntryView.swift | 61 +++++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index b2d652209..63d9d1c90 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -12,9 +12,10 @@ struct BolusPresetsView: View { @EnvironmentObject private var navigator: WatchNavigator @State private var staleRecommendation: Double? - /// Same cut-offs as the phone's bolus screens. + /// Same cut-offs and warning as the phone's bolus screens. private static let recommendationMaxAge: TimeInterval = 12 * 60 static let recommendationWarnAge: TimeInterval = 5 * 60 + static let oldCalculationWarning = "This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution." static func quickPicks(_ remote: WatchRemoteConfig) -> [Double] { remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus } @@ -29,8 +30,15 @@ struct BolusPresetsView: View { return (stepped, age) } + /// Under 5 minutes old the recommendation is already the starting amount, + /// so it is only listed here once it needs the warning. + static func presetRecommendation(_ remote: WatchRemoteConfig) -> (units: Double, age: TimeInterval)? { + guard let rec = freshRecommendation(remote), rec.age >= recommendationWarnAge else { return nil } + return rec + } + static func hasPresets(_ remote: WatchRemoteConfig) -> Bool { - freshRecommendation(remote) != nil || !quickPicks(remote).isEmpty + presetRecommendation(remote) != nil || !quickPicks(remote).isEmpty } var body: some View { @@ -54,26 +62,22 @@ struct BolusPresetsView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution.") + Text(Self.oldCalculationWarning) } } @ViewBuilder private var recommendation: some View { - if let rec = Self.freshRecommendation(remote) { + if let rec = Self.presetRecommendation(remote) { Section { Button { - if rec.age >= Self.recommendationWarnAge { - staleRecommendation = rec.units - } else { - confirm(rec.units) - } + staleRecommendation = rec.units } label: { VStack(alignment: .leading, spacing: 2) { Text("Recommended \(WatchFormat.insulin(rec.units)) U") Text("Calculated \(Int(rec.age / 60)) min ago") .font(.caption2) - .foregroundStyle(rec.age >= Self.recommendationWarnAge ? .red : .secondary) + .foregroundStyle(.red) } } } @@ -95,7 +99,8 @@ struct BolusAmountView: View { @State private var units = 0.0 /// Seeded once, so coming back from the confirmation keeps the user's amount. @State private var seeded = false - @State private var seededRecommendation: (units: Double, age: TimeInterval)? + @State private var seededRecommendation: (units: Double, at: Date)? + @State private var showOldCalculationWarning = false @FocusState private var amountFocused: Bool var body: some View { @@ -104,13 +109,21 @@ struct BolusAmountView: View { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) if let rec = seededRecommendation { - Label("\(WatchFormat.insulin(rec.units)) U, \(Int(rec.age / 60)) min ago", systemImage: "wand.and.stars") - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityLabel("Recommended \(WatchFormat.insulin(rec.units)) U, \(Int(rec.age / 60)) min ago") + // Redrawn every few seconds so the age keeps counting while the screen stays open. + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("\(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + .accessibilityLabel("Recommended \(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago") + } } Button("Continue") { - navigator.push(.confirm(Self.request(units))) + if isStaleRecommendation { + showOldCalculationWarning = true + } else { + navigator.push(.confirm(Self.request(units))) + } } .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) @@ -124,17 +137,31 @@ struct BolusAmountView: View { } } .navigationTitle("Bolus") + .alert("Old Calculation Warning", isPresented: $showOldCalculationWarning) { + Button("Use Anyway") { + navigator.push(.confirm(Self.request(units))) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(BolusPresetsView.oldCalculationWarning) + } .onAppear { amountFocused = true guard !seeded else { return } seeded = true if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { units = rec.units - seededRecommendation = rec + seededRecommendation = (rec.units, Date().addingTimeInterval(-rec.age)) } } } + /// The amount is still the starting recommendation, which has since passed 5 minutes. + private var isStaleRecommendation: Bool { + guard let rec = seededRecommendation, units == rec.units else { return false } + return Date().timeIntervalSince(rec.at) >= BolusPresetsView.recommendationWarnAge + } + static func step(_ remote: WatchRemoteConfig) -> Double { max(remote.bolusIncrement, 0.025) } From ec056ddeec25930681485a30c4e1dc58c4412f05 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:55:00 -0400 Subject: [PATCH 3/9] Follow and check the latest bolus recommendation on the watch, and mark recommended amounts on the confirmation --- LoopFollowWatch/ContentView.swift | 2 + LoopFollowWatch/Remote/BolusEntryView.swift | 84 +++++++++++++------ .../Remote/CommandConfirmView.swift | 60 ++++++++++++- 3 files changed, 118 insertions(+), 28 deletions(-) diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index 073a59fcc..5db091911 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -21,6 +21,8 @@ struct CommandRequest: Hashable { let lines: [String] let action: WatchCommand.Action let deliversInsulin: Bool + /// Set when a bolus amount is the AID's recommendation, so it can be checked again before sending. + var recommendation: BolusRecommendation? = nil } /// Owns the navigation path so a finished command can return to the glucose page. diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 63d9d1c90..12dbf2844 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -4,13 +4,21 @@ import SwiftUI import WatchKit +/// The AID's recommendation at the watch's bolus step. +struct BolusRecommendation: Hashable { + let units: Double + let at: Date + + var age: TimeInterval { Date().timeIntervalSince(at) } +} + /// The AID's recommendation and the phone's Quick-Pick boluses, opened from the /// amount screen. Every choice still needs the crown confirmation. struct BolusPresetsView: View { let remote: WatchRemoteConfig @EnvironmentObject private var navigator: WatchNavigator - @State private var staleRecommendation: Double? + @State private var staleRecommendation: BolusRecommendation? /// Same cut-offs and warning as the phone's bolus screens. private static let recommendationMaxAge: TimeInterval = 12 * 60 @@ -21,18 +29,23 @@ struct BolusPresetsView: View { remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus } } - /// A recommendation recent enough to offer, rounded down to the watch's step. - static func freshRecommendation(_ remote: WatchRemoteConfig) -> (units: Double, age: TimeInterval)? { - guard let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt else { return nil } - let age = Date().timeIntervalSince(at) - let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)) - guard age < recommendationMaxAge, stepped > 0 else { return nil } - return (stepped, age) + /// The latest recommendation whatever its age, rounded down to the watch's + /// step; 0 U when the AID recommends nothing. + static func latestRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let at = remote.recommendedBolusAt else { return nil } + let rec = max(remote.recommendedBolus ?? 0, 0) + return BolusRecommendation(units: BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)), at: at) + } + + /// A recommendation recent enough to offer. + static func freshRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let rec = latestRecommendation(remote), rec.units > 0, rec.age < recommendationMaxAge else { return nil } + return rec } /// Under 5 minutes old the recommendation is already the starting amount, /// so it is only listed here once it needs the warning. - static func presetRecommendation(_ remote: WatchRemoteConfig) -> (units: Double, age: TimeInterval)? { + static func presetRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { guard let rec = freshRecommendation(remote), rec.age >= recommendationWarnAge else { return nil } return rec } @@ -46,7 +59,7 @@ struct BolusPresetsView: View { recommendation ForEach(Self.quickPicks(remote), id: \.self) { units in Button { - confirm(units) + navigator.push(.confirm(BolusAmountView.request(units))) } label: { Text("\(WatchFormat.insulin(units)) U") } @@ -58,7 +71,9 @@ struct BolusPresetsView: View { isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) ) { Button("Use Anyway") { - if let staleRecommendation { confirm(staleRecommendation) } + if let staleRecommendation { + navigator.push(.confirm(BolusAmountView.request(staleRecommendation.units, recommendation: staleRecommendation))) + } } Button("Cancel", role: .cancel) {} } message: { @@ -71,7 +86,7 @@ struct BolusPresetsView: View { if let rec = Self.presetRecommendation(remote) { Section { Button { - staleRecommendation = rec.units + staleRecommendation = rec } label: { VStack(alignment: .leading, spacing: 2) { Text("Recommended \(WatchFormat.insulin(rec.units)) U") @@ -83,23 +98,20 @@ struct BolusPresetsView: View { } } } - - private func confirm(_ units: Double) { - navigator.push(.confirm(BolusAmountView.request(units))) - } } /// Any amount, set with the Digital Crown or − and +, starting at the AID's -/// recommendation while it is under 5 minutes old; presets one tap away when -/// the phone has any. +/// recommendation while it is under 5 minutes old and following newer ones +/// until the amount is changed; presets one tap away when the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig + @EnvironmentObject private var session: WatchSession @EnvironmentObject private var navigator: WatchNavigator @State private var units = 0.0 /// Seeded once, so coming back from the confirmation keeps the user's amount. @State private var seeded = false - @State private var seededRecommendation: (units: Double, at: Date)? + @State private var seededRecommendation: BolusRecommendation? @State private var showOldCalculationWarning = false @FocusState private var amountFocused: Bool @@ -122,7 +134,7 @@ struct BolusAmountView: View { if isStaleRecommendation { showOldCalculationWarning = true } else { - navigator.push(.confirm(Self.request(units))) + continueToConfirm() } } .buttonStyle(CompactButtonStyle(prominent: true)) @@ -139,7 +151,7 @@ struct BolusAmountView: View { .navigationTitle("Bolus") .alert("Old Calculation Warning", isPresented: $showOldCalculationWarning) { Button("Use Anyway") { - navigator.push(.confirm(Self.request(units))) + continueToConfirm() } Button("Cancel", role: .cancel) {} } message: { @@ -149,11 +161,29 @@ struct BolusAmountView: View { amountFocused = true guard !seeded else { return } seeded = true + // The phone also sends new loop cycles as they come; this covers one it has not sent yet. + session.requestState() if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { units = rec.units - seededRecommendation = (rec.units, Date().addingTimeInterval(-rec.age)) + seededRecommendation = rec } } + .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in + followNewerRecommendation() + } + } + + /// An amount still at the starting recommendation moves to a newer one. + private func followNewerRecommendation() { + guard let seededRecommendation, units == seededRecommendation.units, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest.at > seededRecommendation.at + else { return } + if latest.units != units { + WKInterfaceDevice.current().play(.notification) + } + units = latest.units + self.seededRecommendation = latest } /// The amount is still the starting recommendation, which has since passed 5 minutes. @@ -166,8 +196,14 @@ struct BolusAmountView: View { max(remote.bolusIncrement, 0.025) } - static func request(_ units: Double) -> CommandRequest { - CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true) + /// The recommendation goes along only while the amount is still the recommendation. + private func continueToConfirm() { + let basis = seededRecommendation.flatMap { $0.units == units ? $0 : nil } + navigator.push(.confirm(Self.request(units, recommendation: basis))) + } + + static func request(_ units: Double, recommendation: BolusRecommendation? = nil) -> CommandRequest { + CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true, recommendation: recommendation) } static func roundedDown(_ value: Double, step: Double) -> Double { diff --git a/LoopFollowWatch/Remote/CommandConfirmView.swift b/LoopFollowWatch/Remote/CommandConfirmView.swift index d7e8cbbb7..c7b5c3038 100644 --- a/LoopFollowWatch/Remote/CommandConfirmView.swift +++ b/LoopFollowWatch/Remote/CommandConfirmView.swift @@ -7,10 +7,17 @@ import WatchKit /// Final step for every command: shows what will be sent, asks for confirmation /// and shows the phone's reply. Insulin needs a full turn of the Digital Crown -/// and an unlocked watch on the wrist. A sent command is not yet delivered: -/// Loop or Trio confirms it later with a notification. +/// and an unlocked watch on the wrist. A bolus taken from the recommendation +/// says so, and is checked against the latest one the watch has before +/// sending. A sent command is not yet delivered: Loop or Trio confirms it +/// later with a notification. struct CommandConfirmView: View { - let request: CommandRequest + /// Replaced when the user switches to a lowered recommendation. + @State private var request: CommandRequest + + init(request: CommandRequest) { + _request = State(initialValue: request) + } @EnvironmentObject private var session: WatchSession @EnvironmentObject private var navigator: WatchNavigator @@ -22,6 +29,8 @@ struct CommandConfirmView: View { @State private var phase: Phase = .confirm @State private var crownProgress = 0.0 @State private var lastCrownChange = Date() + @State private var lowerRecommendation: BolusRecommendation? + @State private var showLowerRecommendation = false @FocusState private var crownFocused: Bool enum Phase: Equatable { @@ -73,6 +82,23 @@ struct CommandConfirmView: View { } .navigationTitle(request.title) .navigationBarBackButtonHidden(phase != .confirm) + .onAppear { + if request.recommendation != nil { session.requestState() } + } + .alert("Recommendation Lowered", isPresented: $showLowerRecommendation, presenting: lowerRecommendation) { latest in + if latest.units > 0 { + // A new amount needs its own turn of the crown. + Button("Change to \(WatchFormat.insulin(latest.units)) U") { + request = BolusAmountView.request(latest.units, recommendation: latest) + } + } + Button("Send Anyway") { + Task { await authenticateAndSend() } + } + Button("Cancel", role: .cancel) {} + } message: { latest in + Text("The recommendation is now \(WatchFormat.insulin(latest.units)) U, down from \(WatchFormat.insulin(request.recommendation?.units ?? 0)) U. New treatments may have occurred.") + } } private var summary: some View { @@ -82,6 +108,14 @@ struct CommandConfirmView: View { .font(.headline) .multilineTextAlignment(.center) } + if let rec = request.recommendation { + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("Recommended, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + } + } } } @@ -111,7 +145,7 @@ struct CommandConfirmView: View { lastCrownChange = Date() if value >= 1 { WKInterfaceDevice.current().play(.success) - Task { await authenticateAndSend() } + Task { await checkRecommendationThenSend() } } else { resetCrownIfIdle() } @@ -128,6 +162,24 @@ struct CommandConfirmView: View { } } + /// Stops before sending when a newer loop cycle lowered the recommendation + /// this amount came from. The screens ask the phone for fresh state when + /// they open, so this uses what the watch already has and never waits. + private func checkRecommendationThenSend() async { + if let basis = request.recommendation { + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation) + if let latest, latest.at > basis.at, latest.units < basis.units { + crownProgress = 0 + phase = .confirm + lowerRecommendation = latest + showLowerRecommendation = true + WKInterfaceDevice.current().play(.notification) + return + } + } + await authenticateAndSend() + } + private func authenticateAndSend() async { switch await Self.authenticate() { case .success: From dda326a88b2a5a070c2c2d0f8b0c3478e7d213ca Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:25:50 -0400 Subject: [PATCH 4/9] Offer a newer watch bolus recommendation with a tap instead of applying it --- LoopFollowWatch/Remote/BolusEntryView.swift | 49 ++++++++++++++------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 12dbf2844..5c2fbd2f0 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -101,8 +101,9 @@ struct BolusPresetsView: View { } /// Any amount, set with the Digital Crown or − and +, starting at the AID's -/// recommendation while it is under 5 minutes old and following newer ones -/// until the amount is changed; presets one tap away when the phone has any. +/// recommendation while it is under 5 minutes old. A newer recommendation is +/// offered with a tap, never applied on its own. Presets are one tap away when +/// the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig @@ -120,7 +121,19 @@ struct BolusAmountView: View { VStack(spacing: 8) { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) - if let rec = seededRecommendation { + if let newer = newerRecommendation { + Button { + units = newer.units + seededRecommendation = newer + amountFocused = true + } label: { + Label("New \(WatchFormat.insulin(newer.units)) U, tap to use", systemImage: "wand.and.stars") + .font(.footnote) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + .accessibilityLabel("Use the new recommendation, \(WatchFormat.insulin(newer.units)) U") + } else if let rec = seededRecommendation { // Redrawn every few seconds so the age keeps counting while the screen stays open. TimelineView(.periodic(from: .now, by: 10)) { context in let age = context.date.timeIntervalSince(rec.at) @@ -169,21 +182,27 @@ struct BolusAmountView: View { } } .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in - followNewerRecommendation() + if newerRecommendation != nil { + WKInterfaceDevice.current().play(.notification) + } else if let rec = seededRecommendation, units == rec.units, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest.at > rec.at, latest.units == rec.units + { + // Same amount from a newer cycle: only the age is refreshed. + seededRecommendation = latest + } } } - /// An amount still at the starting recommendation moves to a newer one. - private func followNewerRecommendation() { - guard let seededRecommendation, units == seededRecommendation.units, - let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), - latest.at > seededRecommendation.at - else { return } - if latest.units != units { - WKInterfaceDevice.current().play(.notification) - } - units = latest.units - self.seededRecommendation = latest + /// A recommendation from a newer loop cycle than the one the amount started + /// from, under 5 minutes old and different from the amount. + private var newerRecommendation: BolusRecommendation? { + guard let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest.at > (seededRecommendation?.at ?? .distantPast), + latest.age < BolusPresetsView.recommendationWarnAge, + latest.units != units + else { return nil } + return latest } /// The amount is still the starting recommendation, which has since passed 5 minutes. From ae24eb7b3bcd1ad716a4023a3d85cb59c4640b66 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:44:24 -0400 Subject: [PATCH 5/9] Revert "Offer a newer watch bolus recommendation with a tap instead of applying it" This reverts commit dda326a88b2a5a070c2c2d0f8b0c3478e7d213ca. --- LoopFollowWatch/Remote/BolusEntryView.swift | 49 +++++++-------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 5c2fbd2f0..12dbf2844 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -101,9 +101,8 @@ struct BolusPresetsView: View { } /// Any amount, set with the Digital Crown or − and +, starting at the AID's -/// recommendation while it is under 5 minutes old. A newer recommendation is -/// offered with a tap, never applied on its own. Presets are one tap away when -/// the phone has any. +/// recommendation while it is under 5 minutes old and following newer ones +/// until the amount is changed; presets one tap away when the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig @@ -121,19 +120,7 @@ struct BolusAmountView: View { VStack(spacing: 8) { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) - if let newer = newerRecommendation { - Button { - units = newer.units - seededRecommendation = newer - amountFocused = true - } label: { - Label("New \(WatchFormat.insulin(newer.units)) U, tap to use", systemImage: "wand.and.stars") - .font(.footnote) - } - .buttonStyle(.plain) - .foregroundStyle(.tint) - .accessibilityLabel("Use the new recommendation, \(WatchFormat.insulin(newer.units)) U") - } else if let rec = seededRecommendation { + if let rec = seededRecommendation { // Redrawn every few seconds so the age keeps counting while the screen stays open. TimelineView(.periodic(from: .now, by: 10)) { context in let age = context.date.timeIntervalSince(rec.at) @@ -182,27 +169,21 @@ struct BolusAmountView: View { } } .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in - if newerRecommendation != nil { - WKInterfaceDevice.current().play(.notification) - } else if let rec = seededRecommendation, units == rec.units, - let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), - latest.at > rec.at, latest.units == rec.units - { - // Same amount from a newer cycle: only the age is refreshed. - seededRecommendation = latest - } + followNewerRecommendation() } } - /// A recommendation from a newer loop cycle than the one the amount started - /// from, under 5 minutes old and different from the amount. - private var newerRecommendation: BolusRecommendation? { - guard let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), - latest.at > (seededRecommendation?.at ?? .distantPast), - latest.age < BolusPresetsView.recommendationWarnAge, - latest.units != units - else { return nil } - return latest + /// An amount still at the starting recommendation moves to a newer one. + private func followNewerRecommendation() { + guard let seededRecommendation, units == seededRecommendation.units, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest.at > seededRecommendation.at + else { return } + if latest.units != units { + WKInterfaceDevice.current().play(.notification) + } + units = latest.units + self.seededRecommendation = latest } /// The amount is still the starting recommendation, which has since passed 5 minutes. From 4219d029d46d567a9698f478d9dd85bfb7092758 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:44:48 -0400 Subject: [PATCH 6/9] Follow a lower watch bolus recommendation, offer a higher one with a tap, and catch up after the confirmation --- LoopFollowWatch/Remote/BolusEntryView.swift | 50 ++++++++++++++++----- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 12dbf2844..77bf865d2 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -101,8 +101,10 @@ struct BolusPresetsView: View { } /// Any amount, set with the Digital Crown or − and +, starting at the AID's -/// recommendation while it is under 5 minutes old and following newer ones -/// until the amount is changed; presets one tap away when the phone has any. +/// recommendation while it is under 5 minutes old. Until the amount is changed +/// by hand, a newer recommendation that is lower or the same replaces it, and a +/// higher one is offered with a tap. Presets are one tap away when the phone +/// has any. struct BolusAmountView: View { let remote: WatchRemoteConfig @@ -112,6 +114,8 @@ struct BolusAmountView: View { /// Seeded once, so coming back from the confirmation keeps the user's amount. @State private var seeded = false @State private var seededRecommendation: BolusRecommendation? + /// A newer, higher recommendation, only applied when tapped. + @State private var higherRecommendation: BolusRecommendation? @State private var showOldCalculationWarning = false @FocusState private var amountFocused: Bool @@ -120,7 +124,20 @@ struct BolusAmountView: View { VStack(spacing: 8) { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) - if let rec = seededRecommendation { + if let higher = higherRecommendation, higher.units != units { + Button { + units = higher.units + seededRecommendation = higher + higherRecommendation = nil + amountFocused = true + } label: { + Label("New \(WatchFormat.insulin(higher.units)) U, tap to use", systemImage: "wand.and.stars") + .font(.footnote) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + .accessibilityLabel("Use the new, higher recommendation, \(WatchFormat.insulin(higher.units)) U") + } else if let rec = seededRecommendation { // Redrawn every few seconds so the age keeps counting while the screen stays open. TimelineView(.periodic(from: .now, by: 10)) { context in let age = context.date.timeIntervalSince(rec.at) @@ -159,7 +176,11 @@ struct BolusAmountView: View { } .onAppear { amountFocused = true - guard !seeded else { return } + guard !seeded else { + // Catches up on a cycle that came in while the confirmation was showing. + followNewerRecommendation() + return + } seeded = true // The phone also sends new loop cycles as they come; this covers one it has not sent yet. session.requestState() @@ -173,22 +194,31 @@ struct BolusAmountView: View { } } - /// An amount still at the starting recommendation moves to a newer one. + /// An amount still at the starting recommendation moves down to a newer, + /// lower one; a higher one is only offered, so insulin is never raised + /// without a tap. private func followNewerRecommendation() { guard let seededRecommendation, units == seededRecommendation.units, let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), - latest.at > seededRecommendation.at + latest.at > seededRecommendation.at, + latest != higherRecommendation else { return } - if latest.units != units { + if latest.units > units { + higherRecommendation = latest + } else { + units = latest.units + self.seededRecommendation = latest + higherRecommendation = nil + } + if latest.units != seededRecommendation.units { WKInterfaceDevice.current().play(.notification) } - units = latest.units - self.seededRecommendation = latest } /// The amount is still the starting recommendation, which has since passed 5 minutes. + /// A newer, higher recommendation means the old one no longer risks too much insulin. private var isStaleRecommendation: Bool { - guard let rec = seededRecommendation, units == rec.units else { return false } + guard let rec = seededRecommendation, units == rec.units, higherRecommendation == nil else { return false } return Date().timeIntervalSince(rec.at) >= BolusPresetsView.recommendationWarnAge } From 2ac50f3e9fba1fb454e4cd2765dae17f52a04fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 28 Oct 2026 19:51:19 +0100 Subject: [PATCH 7/9] Offer a recommendation that arrives after the watch bolus opened, follow only untouched amounts while on top, clear an expired starting recommendation, and let compact buttons wrap A loop cycle without a recommendation no longer counts as 0 U. The Presets button appears once the recommendation is old enough to be listed there, and meal Quick-Picks are centered like the other buttons. --- LoopFollowWatch/Remote/BolusEntryView.swift | 87 ++++++++++++++------- LoopFollowWatch/Remote/MealEntryView.swift | 1 - 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 77bf865d2..f2e434f64 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -21,7 +21,7 @@ struct BolusPresetsView: View { @State private var staleRecommendation: BolusRecommendation? /// Same cut-offs and warning as the phone's bolus screens. - private static let recommendationMaxAge: TimeInterval = 12 * 60 + static let recommendationMaxAge: TimeInterval = 12 * 60 static let recommendationWarnAge: TimeInterval = 5 * 60 static let oldCalculationWarning = "This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution." @@ -30,10 +30,10 @@ struct BolusPresetsView: View { } /// The latest recommendation whatever its age, rounded down to the watch's - /// step; 0 U when the AID recommends nothing. + /// step; nil when the loop cycle carries none. static func latestRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { - guard let at = remote.recommendedBolusAt else { return nil } - let rec = max(remote.recommendedBolus ?? 0, 0) + guard let at = remote.recommendedBolusAt, let recommended = remote.recommendedBolus else { return nil } + let rec = max(recommended, 0) return BolusRecommendation(units: BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)), at: at) } @@ -103,8 +103,8 @@ struct BolusPresetsView: View { /// Any amount, set with the Digital Crown or − and +, starting at the AID's /// recommendation while it is under 5 minutes old. Until the amount is changed /// by hand, a newer recommendation that is lower or the same replaces it, and a -/// higher one is offered with a tap. Presets are one tap away when the phone -/// has any. +/// higher one is offered with a tap. A starting recommendation past 12 minutes +/// is cleared at Continue. Presets are one tap away when the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig @@ -114,9 +114,15 @@ struct BolusAmountView: View { /// Seeded once, so coming back from the confirmation keeps the user's amount. @State private var seeded = false @State private var seededRecommendation: BolusRecommendation? + /// True until the amount is changed by hand. + @State private var followsRecommendation = true /// A newer, higher recommendation, only applied when tapped. @State private var higherRecommendation: BolusRecommendation? + /// Recommendations are followed only while this screen is on top; the + /// confirmation checks its own amount. + @State private var isVisible = false @State private var showOldCalculationWarning = false + @State private var showExpiredRecommendation = false @FocusState private var amountFocused: Bool var body: some View { @@ -126,8 +132,9 @@ struct BolusAmountView: View { .focused($amountFocused) if let higher = higherRecommendation, higher.units != units { Button { - units = higher.units seededRecommendation = higher + units = higher.units + followsRecommendation = true higherRecommendation = nil amountFocused = true } label: { @@ -148,7 +155,9 @@ struct BolusAmountView: View { } } Button("Continue") { - if isStaleRecommendation { + if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationMaxAge { + showExpiredRecommendation = true + } else if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationWarnAge { showOldCalculationWarning = true } else { continueToConfirm() @@ -157,11 +166,14 @@ struct BolusAmountView: View { .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(units <= 0) - if BolusPresetsView.hasPresets(remote) { - Button("Presets") { - navigator.push(.bolusPresets) + // Redrawn so the button appears once the recommendation is old enough to be listed there. + TimelineView(.periodic(from: .now, by: 10)) { _ in + if BolusPresetsView.hasPresets(remote) { + Button("Presets") { + navigator.push(.bolusPresets) + } + .buttonStyle(CompactButtonStyle()) } - .buttonStyle(CompactButtonStyle()) } } } @@ -174,10 +186,20 @@ struct BolusAmountView: View { } message: { Text(BolusPresetsView.oldCalculationWarning) } + .alert("Recommendation Expired", isPresented: $showExpiredRecommendation) { + Button("OK") { + seededRecommendation = nil + units = 0 + followsRecommendation = true + } + } message: { + Text("This recommended bolus was calculated more than 12 minutes ago. Set the amount again.") + } .onAppear { + isVisible = true amountFocused = true guard !seeded else { - // Catches up on a cycle that came in while the confirmation was showing. + // Catches up on a cycle that came in while another screen was on top. followNewerRecommendation() return } @@ -185,41 +207,50 @@ struct BolusAmountView: View { // The phone also sends new loop cycles as they come; this covers one it has not sent yet. session.requestState() if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { - units = rec.units seededRecommendation = rec + units = rec.units } } + .onDisappear { isVisible = false } + .onChange(of: units) { _, newUnits in + if newUnits != (seededRecommendation?.units ?? 0) { followsRecommendation = false } + } .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in followNewerRecommendation() } } - /// An amount still at the starting recommendation moves down to a newer, - /// lower one; a higher one is only offered, so insulin is never raised - /// without a tap. + /// An untouched amount moves down to a newer, lower recommendation; a + /// higher one, or the first fresh one when nothing was filled in, is only + /// offered, so insulin is never raised without a tap. private func followNewerRecommendation() { - guard let seededRecommendation, units == seededRecommendation.units, + guard isVisible, followsRecommendation, let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), - latest.at > seededRecommendation.at, latest != higherRecommendation else { return } + if let seededRecommendation { + guard latest.at > seededRecommendation.at else { return } + } else { + guard latest.units > 0, latest.age < BolusPresetsView.recommendationWarnAge else { return } + } + let previousUnits = units if latest.units > units { higherRecommendation = latest } else { + seededRecommendation = latest units = latest.units - self.seededRecommendation = latest higherRecommendation = nil } - if latest.units != seededRecommendation.units { + if latest.units != previousUnits { WKInterfaceDevice.current().play(.notification) } } - /// The amount is still the starting recommendation, which has since passed 5 minutes. - /// A newer, higher recommendation means the old one no longer risks too much insulin. - private var isStaleRecommendation: Bool { - guard let rec = seededRecommendation, units == rec.units, higherRecommendation == nil else { return false } - return Date().timeIntervalSince(rec.at) >= BolusPresetsView.recommendationWarnAge + /// Age of the starting recommendation while it is still the amount. A newer, + /// higher recommendation means the old one no longer risks too much insulin. + private var startingRecommendationAge: TimeInterval? { + guard let rec = seededRecommendation, units == rec.units, higherRecommendation == nil else { return nil } + return rec.age } static func step(_ remote: WatchRemoteConfig) -> Double { @@ -319,10 +350,12 @@ struct CompactButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .lineLimit(1) + .lineLimit(2) .minimumScaleFactor(0.7) + .multilineTextAlignment(.center) .foregroundStyle(prominent ? AnyShapeStyle(Color.white) : AnyShapeStyle(.primary)) .padding(.horizontal, 12) + .padding(.vertical, 4) .frame(maxWidth: .infinity, minHeight: Self.height) .background(Capsule().fill(prominent ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.gray.opacity(0.3)))) .opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4) diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift index bc6529003..3be27fdd7 100644 --- a/LoopFollowWatch/Remote/MealEntryView.swift +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -87,7 +87,6 @@ struct MealEntryView: View { navigator.push(.confirm(request(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: 0))) } label: { Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) - .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(CompactButtonStyle()) } From 77505487a2db7627e7055156949f798531b0edef Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:54:18 -0400 Subject: [PATCH 8/9] Don't offer a bolus recommendation calculated before the last bolus After a bolus, the recommendation stays the same until Loop or Trio runs a new cycle and LoopFollow fetches it, which can take a few minutes. During that time the watch filled the old amount in again, so Continue and the crown would have given the bolus twice. The phone's bolus screens offered it too, with a tap. The phone now leaves out a recommendation when a manual bolus came after its loop cycle: from Nightscout, or sent from LoopFollow or the watch, alone or with a meal. The watch also ignores one from before insulin it sent itself, including when it sent directly. For Loop, the recommendation's time is now the cycle's own timestamp. The pump clock it used before also moves on failed cycles, which keep the old recommendation, so after a bolus that recommendation looked newer than the bolus. --- .../Nightscout/DeviceStatusLoop.swift | 3 ++ .../Nightscout/DeviceStatusOpenAPS.swift | 2 ++ .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 5 ++-- LoopFollow/Remote/RecommendedBolus.swift | 28 +++++++++++++++++++ LoopFollow/Remote/TRC/BolusView.swift | 14 ++++------ LoopFollow/Storage/Observable.swift | 3 ++ LoopFollow/Watch/WatchStateBuilder.swift | 9 ++---- LoopFollowWatch/Remote/BolusEntryView.swift | 5 +++- LoopFollowWatch/WatchSession.swift | 12 ++++++++ 9 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 LoopFollow/Remote/RecommendedBolus.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 8414b2457..4e3b6b5bc 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -108,9 +108,12 @@ extension MainViewController { if let recBolus = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus) Observable.shared.deviceRecBolus.value = recBolus + let cycleTime = (lastLoopRecord["timestamp"] as? String).flatMap { formatter.date(from: $0)?.timeIntervalSince1970 } + Observable.shared.deviceRecBolusAt.value = cycleTime ?? lastLoopTime } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { if let tempBasalString = loopStatus["timestamp"] as? String, diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index e9cb08ba5..0a994ef86 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -121,9 +121,11 @@ extension MainViewController { if let rec = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(rec)) Observable.shared.deviceRecBolus.value = rec + Observable.shared.deviceRecBolusAt.value = updatedTime ?? Observable.shared.enactedOrSuggested.value } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } // Eventual BG diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 09222270c..59260eb55 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -307,8 +307,9 @@ struct LoopAPNSBolusView: View { private func loadRecommendedBolus() { // Load recommended bolus from Observable - recommendedBolus = Observable.shared.deviceRecBolus.value - lastLoopTime = Observable.shared.alertLastLoopTime.value + let recommended = RecommendedBolus.current() + recommendedBolus = recommended?.units + lastLoopTime = recommended?.at // Reset warning state when new data is loaded showOldCalculationWarning = false diff --git a/LoopFollow/Remote/RecommendedBolus.swift b/LoopFollow/Remote/RecommendedBolus.swift new file mode 100644 index 000000000..9d4ac9015 --- /dev/null +++ b/LoopFollow/Remote/RecommendedBolus.swift @@ -0,0 +1,28 @@ +// LoopFollow +// RecommendedBolus.swift + +import Foundation + +/// The AID's latest bolus recommendation, offered only while no bolus came +/// after the loop cycle that calculated it. Until the next cycle, an earlier +/// recommendation leaves out that bolus, and using it could double it. +enum RecommendedBolus { + static func current() -> (units: Double, at: TimeInterval)? { + guard let units = Observable.shared.deviceRecBolus.value, + let at = Observable.shared.deviceRecBolusAt.value + else { return nil } + if let lastBolus = lastBolusAt(), lastBolus >= at { return nil } + return (units, at) + } + + /// The latest manual bolus in Nightscout, or sent from LoopFollow or the + /// watch, which Nightscout may not show yet. Automatic boluses are left + /// out: the cycle that recommends a bolus can also give one. + static func lastBolusAt() -> TimeInterval? { + let nightscout = MainViewController.shared?.bolusData.map(\.date).max() + let sentBolus = Storage.shared.remoteBolusHistory.value.map(\.date).max() + let sentMeal = Storage.shared.remoteMealHistory.value.filter { $0.bolus > 0 }.map(\.date).max() + let sent = [sentBolus, sentMeal].compactMap { $0?.timeIntervalSince1970 }.max() + return [nightscout, sent].compactMap { $0 }.max() + } +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index e41ab89ce..c0be6e408 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -12,8 +12,6 @@ struct BolusView: View { @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 @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @@ -203,8 +201,8 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value { - applyRecommendedBolus(rec) + if let rec = RecommendedBolus.current() { + applyRecommendedBolus(rec.units) } }, secondaryButton: .cancel() @@ -220,10 +218,10 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { - if let rec = deviceRecBolus.value, - let t = enactedOrSuggested.value - { - let ageSec = max(0, now.timeIntervalSince1970 - t) + // The TimelineView redraws this every second, so a new loop cycle or bolus shows up here. + if let recommended = RecommendedBolus.current() { + let rec = recommended.units + let ageSec = max(0, now.timeIntervalSince1970 - recommended.at) if ageSec < 12 * 60 { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 645703b6a..8fc9f19d6 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -45,6 +45,9 @@ class Observable { var alertLastLoopTime = ObservableValue(default: nil) var previousAlertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) + /// When the loop cycle behind `deviceRecBolus` ran. Unlike `alertLastLoopTime`, + /// a failed cycle, which keeps the previous recommendation, does not move it. + var deviceRecBolusAt = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) var deviceBatteryIsCharging = ObservableValue(default: nil) var pumpBatteryLevel = ObservableValue(default: nil) diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index 21d89021c..12a379c99 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -138,10 +138,7 @@ enum WatchStateBuilder { let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) let mealWithFatProtein = kind == .trio && Storage.shared.mealWithFatProtein.value let now = Date() - - let recommendedAt: TimeInterval? = kind == .trio - ? Observable.shared.enactedOrSuggested.value - : Observable.shared.alertLastLoopTime.value + let recommended = RecommendedBolus.current() return WatchRemoteConfig( kind: kind, @@ -152,8 +149,8 @@ enum WatchStateBuilder { maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), mealWithBolus: kind == .trio && Storage.shared.mealWithBolus.value, mealWithFatProtein: mealWithFatProtein, - recommendedBolus: Observable.shared.deviceRecBolus.value, - recommendedBolusAt: recommendedAt.map { Date(timeIntervalSince1970: $0) }, + recommendedBolus: recommended?.units, + recommendedBolusAt: recommended.map { Date(timeIntervalSince1970: $0.at) }, overridePresets: kind == .trio ? trioOverridePresets() : loopOverridePresets(), tempTargetPresets: kind == .trio ? tempTargetPresets() : [], quickPickBoluses: QuickPickBolusesManager.computeQuickPickBoluses( diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index f2e434f64..211d32eca 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -30,9 +30,12 @@ struct BolusPresetsView: View { } /// The latest recommendation whatever its age, rounded down to the watch's - /// step; nil when the loop cycle carries none. + /// step; nil when the loop cycle carries none, or ran before insulin was + /// last sent from the watch and so leaves that insulin out. The phone drops + /// one from before any other bolus. static func latestRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { guard let at = remote.recommendedBolusAt, let recommended = remote.recommendedBolus else { return nil } + if let sent = WatchSession.lastInsulinSentAt, at <= sent { return nil } let rec = max(recommended, 0) return BolusRecommendation(units: BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)), at: at) } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index c6101921b..7b5c7512b 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -85,11 +85,23 @@ final class WatchSession: NSObject, ObservableObject { } } + nonisolated private static let lastInsulinSentKey = "lastInsulinSentAt" + + /// When insulin was last sent from the watch, through the phone or directly. + /// Set before sending, since a command without a clear reply may still have + /// gone through. + nonisolated static var lastInsulinSentAt: Date? { + UserDefaults.standard.object(forKey: lastInsulinSentKey) as? Date + } + /// Sends a command through the phone when it is reachable, otherwise directly. /// Falls back to sending directly only when the phone turned out to be out of /// reach before the command was delivered; a command that may have reached /// the phone is never sent a second time. func send(_ action: WatchCommand.Action) async -> WatchCommandReply { + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: Self.lastInsulinSentKey) + } if session.activationState == .activated, session.isReachable { switch await sendThroughPhone(action) { case let .replied(reply): return reply From 3431898db3aba6c926511ed3c9e786c95abe3c8d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:25:15 -0400 Subject: [PATCH 9/9] Order the watch's last-insulin key modifiers the way SwiftFormat expects --- LoopFollowWatch/WatchSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index 7b5c7512b..2a0c8892c 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -85,7 +85,7 @@ final class WatchSession: NSObject, ObservableObject { } } - nonisolated private static let lastInsulinSentKey = "lastInsulinSentAt" + private nonisolated static let lastInsulinSentKey = "lastInsulinSentAt" /// When insulin was last sent from the watch, through the phone or directly. /// Set before sending, since a command without a clear reply may still have