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/ContentView.swift b/LoopFollowWatch/ContentView.swift index f8abc2df1..5db091911 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) @@ -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. @@ -75,9 +77,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..211d32eca 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -2,89 +2,130 @@ // 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 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. + 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 } + } + + /// The latest recommendation whatever its age, rounded down to the watch's + /// 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) + } - /// Same cut-offs as the phone's bolus screens. - private let recommendationMaxAge: TimeInterval = 12 * 60 - private let recommendationWarnAge: TimeInterval = 5 * 60 + /// 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) -> BolusRecommendation? { + guard let rec = freshRecommendation(remote), rec.age >= recommendationWarnAge else { return nil } + return rec + } - private var step: Double { BolusAmountView.step(remote) } + static func hasPresets(_ remote: WatchRemoteConfig) -> Bool { + presetRecommendation(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) + navigator.push(.confirm(BolusAmountView.request(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 } }) ) { 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: { - 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 = 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.presetRecommendation(remote) { + Section { + Button { + staleRecommendation = rec + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(rec.units)) U") + Text("Calculated \(Int(rec.age / 60)) min ago") + .font(.caption2) + .foregroundStyle(.red) } - .disabled(stepped <= 0) } } } } - - private func confirm(_ units: Double) { - navigator.push(.confirm(BolusAmountView.request(units))) - } } -/// 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. 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. 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 + @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: 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 { @@ -92,27 +133,141 @@ 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 higher = higherRecommendation, higher.units != units { + Button { + seededRecommendation = higher + units = higher.units + followsRecommendation = true + 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) + 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 let age = startingRecommendationAge, age >= BolusPresetsView.recommendationMaxAge { + showExpiredRecommendation = true + } else if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationWarnAge { + showOldCalculationWarning = true + } else { + continueToConfirm() + } } - .buttonStyle(.borderedProminent) + .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(units <= 0) + // 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()) + } + } } } .navigationTitle("Bolus") - .onAppear { amountFocused = true } + .alert("Old Calculation Warning", isPresented: $showOldCalculationWarning) { + Button("Use Anyway") { + continueToConfirm() + } + Button("Cancel", role: .cancel) {} + } 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 another screen was on top. + followNewerRecommendation() + 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 { + 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 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 isVisible, followsRecommendation, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + 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 + higherRecommendation = nil + } + if latest.units != previousUnits { + WKInterfaceDevice.current().play(.notification) + } + } + + /// 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 { 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 { @@ -174,6 +329,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 +340,27 @@ 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(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/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: diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift index 504e06d0a..3be27fdd7 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) } @@ -89,9 +87,8 @@ 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) } - .frame(maxWidth: .infinity) + .buttonStyle(CompactButtonStyle()) } } } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index c6101921b..2a0c8892c 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -85,11 +85,23 @@ final class WatchSession: NSObject, ObservableObject { } } + 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 + /// 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