Przeglądaj źródła

Merge pull request #1130 from nightscout/feat/treatment-view-bolus-progress

Show in-progress Bolus on Treatments screen (#1003)
Deniz Cengiz 2 tygodni temu
rodzic
commit
631b761fd2

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -190,6 +190,7 @@
 		38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A3625F5509500C0CED0 /* String+Extensions.swift */; };
 		38EA05DA261F6E7C0064E39B /* SimpleLogReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */; };
 		38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */; };
+		DDB0BBA02026050100000001 /* BolusProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0BBA02026050100000002 /* BolusProgressBar.swift */; };
 		38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F37827261260DC009DB701 /* Color+Extensions.swift */; };
 		38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
@@ -1050,6 +1051,7 @@
 		38E98A3625F5509500C0CED0 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
 		38EA05D9261F6E7C0064E39B /* SimpleLogReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleLogReporter.swift; sourceTree = "<group>"; };
 		38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressViewStyle.swift; sourceTree = "<group>"; };
+		DDB0BBA02026050100000002 /* BolusProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressBar.swift; sourceTree = "<group>"; };
 		38F37827261260DC009DB701 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
 		38F3783A2613555C009DB701 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
 		38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetsStorage.swift; sourceTree = "<group>"; };
@@ -2321,6 +2323,7 @@
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
 				389ECDFD2601061500D86C4F /* View+Snapshot.swift */,
 				38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */,
+				DDB0BBA02026050100000002 /* BolusProgressBar.swift */,
 				38DF1785276A73D400B3528F /* TagCloudView.swift */,
 				DD88C8E12C50420800F2D558 /* DefinitionRow.swift */,
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
@@ -4556,6 +4559,7 @@
 				582DF9792C8CE1E5001F516D /* MainChartHelper.swift in Sources */,
 				E06B911A275B5EEA003C04B6 /* Array+Extension.swift in Sources */,
 				38EA0600262091870064E39B /* BolusProgressViewStyle.swift in Sources */,
+				DDB0BBA02026050100000001 /* BolusProgressBar.swift in Sources */,
 				389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */,
 				38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */,
 				38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */,

+ 4 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -60771,6 +60771,10 @@
         }
       }
     },
+    "Cancel bolus" : {
+      "comment" : "A button that cancels a bolus.",
+      "isCommentAutoGenerated" : true
+    },
     "Cancel override" : {
       "localizations" : {
         "bg" : {

+ 8 - 29
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -773,27 +773,6 @@ extension Home {
             }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
         }
 
-        @ViewBuilder func bolusProgressBar(_ progress: Decimal) -> some View {
-            GeometryReader { geo in
-                RoundedRectangle(cornerRadius: 15)
-                    .frame(height: 6)
-                    .foregroundColor(.clear)
-                    .background(
-                        LinearGradient(colors: [
-                            Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
-                            Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
-                            Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
-                            Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
-                            Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
-                        ], startPoint: .leading, endPoint: .trailing)
-                            .mask(alignment: .leading) {
-                                RoundedRectangle(cornerRadius: 15)
-                                    .frame(width: geo.size.width * CGFloat(progress))
-                            }
-                    )
-            }
-        }
-
         @ViewBuilder func bolusView(geo: GeometryProxy, _ progress: Decimal) -> some View {
             /// ensure that state.lastPumpBolus has a value, i.e. there is a last bolus done by the pump and not an external bolus
             /// - TRUE:  show the pump bolus
@@ -849,14 +828,14 @@ extension Home {
                         }
                     }.padding(.horizontal, 10)
                         .padding(.trailing, 8)
-
-                }.padding(.horizontal, 10).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
-                    .overlay(alignment: .bottom) {
-                        // Use a geo-based offset here to position progress bar independent of device size
-                        let offset = geo.size.height * 0.0725
-                        bolusProgressBar(progress).padding(.horizontal, 18)
-                            .offset(y: offset)
-                    }.clipShape(RoundedRectangle(cornerRadius: 15))
+                }
+                .padding(.horizontal, 10)
+                .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 10))
+                .overlay(alignment: .bottom) {
+                    BolusProgressBar(progress: progress)
+                        .padding(.horizontal, 18)
+                        .padding(.bottom, 9)
+                }.clipShape(RoundedRectangle(cornerRadius: 15))
             }
         }
 

+ 70 - 17
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -124,6 +124,7 @@ extension Treatments {
         let viewContext = CoreDataStack.shared.persistentContainer.viewContext
         let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
         let determinationFetchContext = CoreDataStack.shared.newTaskContext()
+        let pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
 
         var isActive: Bool = false
 
@@ -137,8 +138,9 @@ extension Treatments {
 
         typealias PumpEvent = PumpEventStored.EventType
 
-        var isBolusInProgress: Bool = false
-        private var bolusProgressCancellable: AnyCancellable?
+        var bolusProgress: Decimal?
+        var isBolusInProgress: Bool { bolusProgress != nil }
+        var lastPumpBolus: PumpEventStored?
 
         func unsubscribe() {
             subscriptions.forEach { $0.cancel() }
@@ -173,7 +175,7 @@ extension Treatments {
             hasCleanedUp = true
 
             unsubscribe()
-            bolusProgressCancellable?.cancel()
+            lifetime = Lifetime()
 
             broadcaster?.unregister(DeterminationObserver.self, observer: self)
             broadcaster?.unregister(BolusFailureObserver.self, observer: self)
@@ -198,6 +200,9 @@ extension Treatments {
                         group.addTask {
                             self.registerObservers()
                         }
+                        group.addTask {
+                            self.setupLastBolus()
+                        }
 
                         // Wait for all tasks to complete
                         try await group.waitForAll()
@@ -208,22 +213,21 @@ extension Treatments {
             }
         }
 
-        /// Observes changes to the `bolusProgress` published by the `apsManager` to update the `isBolusInProgress` property in real time.
-        ///
-        /// - Important:
-        ///   - `apsManager.bolusProgress` is a `CurrentValueSubject<Decimal?, Never>`.
-        ///   - When a bolus starts, this subject emits `0` (or a fraction like `0.1, 0.5, etc.`).
-        ///   - When the bolus finishes, the subject is typically set to `nil`.
-        ///   - This treats ANY non-nil value as "bolus in progress."
-        ///
+        /// Mirrors `apsManager.bolusProgress` (a `CurrentValueSubject<Decimal?, Never>`) directly into the
+        /// state model so the View can read both the progress fraction (0.0–1.0) and a derived in-progress
+        /// flag. Stored in `lifetime` to match the Home module's pattern (HomeStateModel.registerObservers).
         private func subscribeToBolusProgress() {
-            bolusProgressCancellable = apsManager.bolusProgress
+            apsManager.bolusProgress
                 .receive(on: DispatchQueue.main)
-                .sink { [weak self] progressValue in
-                    guard let self = self else { return }
-                    // If progressValue is non-nil, a bolus is in progress.
-                    self.isBolusInProgress = (progressValue != nil)
-                }
+                .weakAssign(to: \.bolusProgress, on: self)
+                .store(in: &lifetime)
+        }
+
+        func cancelBolus() {
+            Task {
+                await apsManager.cancelBolus(nil)
+                try? await apsManager.determineBasalSync()
+            }
         }
 
         // MARK: - Basal
@@ -744,6 +748,11 @@ extension Treatments.StateModel {
             guard let self = self else { return }
             self.setupGlucoseArray()
         }.store(in: &subscriptions)
+
+        // Refresh `lastPumpBolus` whenever a new pump event lands (mirrors HomeStateModel)
+        coreDataPublisher?.filteredByEntityName("PumpEventStored").sink { [weak self] _ in
+            self?.setupLastBolus()
+        }.store(in: &subscriptions)
     }
 
     private func registerSubscribers() {
@@ -999,3 +1008,47 @@ private extension Predictions {
         iob == nil && zt == nil && cob == nil && uam == nil
     }
 }
+
+// MARK: - Last Pump Bolus
+
+extension Treatments.StateModel {
+    /// Mirrors `HomeStateModel.setupLastBolus` so the in-progress visualizer can show the
+    /// running pump-bolus's amount as the denominator (not the user's pending entry).
+    /// Filters out external boluses via `NSPredicate.lastPumpBolus`.
+    func setupLastBolus() {
+        Task {
+            do {
+                guard let id = try await fetchLastBolus() else { return }
+                await updateLastBolus(with: id)
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Error setting up last bolus: \(error)")
+            }
+        }
+    }
+
+    private func fetchLastBolus() async throws -> NSManagedObjectID? {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: pumpHistoryFetchContext,
+            predicate: NSPredicate.lastPumpBolus,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        return try await pumpHistoryFetchContext.perform {
+            guard let fetched = results as? [PumpEventStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+            return fetched.map(\.objectID).first
+        }
+    }
+
+    @MainActor private func updateLastBolus(with id: NSManagedObjectID) {
+        do {
+            lastPumpBolus = try viewContext.existingObject(with: id) as? PumpEventStored
+        } catch {
+            debug(.default, "\(DebuggingIdentifiers.failed) updateLastBolus: \(error)")
+        }
+    }
+}

+ 126 - 36
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -42,6 +42,23 @@ extension Treatments {
             return formatter
         }
 
+        private var bolusProgressFormatter: NumberFormatter {
+            let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement {
+            case 0.1: 1
+            case 0.025: 3
+            default: 2
+            }
+
+            let formatter = NumberFormatter()
+            formatter.numberStyle = .decimal
+            formatter.minimum = 0
+            formatter.maximumFractionDigits = fractionDigits
+            formatter.minimumFractionDigits = fractionDigits
+            formatter.allowsFloats = true
+            formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber
+            return formatter
+        }
+
         private var mealFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
@@ -475,6 +492,9 @@ extension Treatments {
         }
 
         var treatmentButton: some View {
+            let shouldDisplayBolusProgress = state.isBolusInProgress && state.amount > 0 &&
+                !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
+
             var treatmentButtonBackground = Color(.systemBlue)
             if limitExceeded {
                 treatmentButtonBackground = Color(.systemRed)
@@ -483,41 +503,43 @@ extension Treatments {
             }
 
             return Section {
-                Button {
-                    if bolusWarning.shouldConfirm {
-                        showConfirmDialogForBolusing = true
-                    } else {
-                        state.invokeTreatmentsTask()
-                    }
-                } label: {
-                    HStack {
-                        if state.isBolusInProgress && state.amount > 0 &&
-                            !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
-                        {
-                            ProgressView()
+                if shouldDisplayBolusProgress {
+                    bolusInProgressView
+                        .listRowBackground(Color.clear)
+                        .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
+                } else {
+                    Button {
+                        if bolusWarning.shouldConfirm {
+                            showConfirmDialogForBolusing = true
+                        } else {
+                            state.invokeTreatmentsTask()
                         }
-                        taskButtonLabel
+                    } label: {
+                        HStack {
+                            taskButtonLabel
+                        }
+                        .font(.headline)
+                        .foregroundStyle(Color.white)
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .frame(height: 35)
                     }
-                    .font(.headline)
-                    .foregroundStyle(Color.white)
-                    .frame(maxWidth: .infinity, alignment: .center)
-                    .frame(height: 35)
-                }
-                .disabled(disableTaskButton)
-                .listRowBackground(treatmentButtonBackground)
-                .shadow(radius: 3)
-                .clipShape(RoundedRectangle(cornerRadius: 8))
-                .confirmationDialog(
-                    bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
-                    isPresented: $showConfirmDialogForBolusing,
-                    titleVisibility: .visible
-                ) {
-                    Button("Cancel", role: .cancel) {}
-                    Button(
-                        bolusWarning.warningMessage.isEmpty ? "Enact Bolus" : "Ignore Warning and Enact Bolus",
-                        role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
+                    .disabled(disableTaskButton)
+                    .listRowBackground(treatmentButtonBackground)
+                    .shadow(radius: 3)
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+                    .confirmationDialog(
+                        bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
+                        isPresented: $showConfirmDialogForBolusing,
+                        titleVisibility: .visible
                     ) {
-                        state.invokeTreatmentsTask()
+                        Button("Cancel", role: .cancel) {}
+                        Button(
+                            bolusWarning.warningMessage
+                                .isEmpty ? String(localized: "Enact Bolus") : String(localized: "Ignore Warning and Enact Bolus"),
+                            role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
+                        ) {
+                            state.invokeTreatmentsTask()
+                        }
                     }
                 }
             } header: {
@@ -532,6 +554,75 @@ extension Treatments {
             }
         }
 
+        /// Card-style in-progress visualizer matching Home's `bolusView` look:
+        /// insulin-tinted background, cross.vial.fill icon, "Bolusing" + "X of Y U" text,
+        /// xmark.app cancel, gradient progress bar overlaid at the bottom.
+        @ViewBuilder private var bolusInProgressView: some View {
+            let progress = state.bolusProgress ?? 0
+            let bolusTotal = state.lastPumpBolus?.bolus?.amount as Decimal?
+            let bolusFraction = (bolusTotal ?? 0) * progress
+            let bolusString: String = {
+                guard let bolusTotal = bolusTotal else { return String(localized: "Bolus In Progress...") }
+                return (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0")
+                    + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view")
+                    + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
+                    + String(localized: " U", comment: "Insulin unit")
+            }()
+
+            ZStack {
+                // background card
+                RoundedRectangle(cornerRadius: 15)
+                    .fill(
+                        colorScheme == .dark
+                            ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745)
+                            : Color.insulin.opacity(0.2)
+                    )
+                    .frame(height: 56)
+                    .shadow(
+                        color: colorScheme == .dark
+                            ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706)
+                            : Color.black.opacity(0.33),
+                        radius: 3
+                    )
+
+                // bolus content
+                HStack {
+                    Image(systemName: "cross.vial.fill")
+                        .font(.system(size: 25))
+
+                    Spacer()
+
+                    VStack {
+                        Text("Bolusing")
+                            .font(.subheadline)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                        Text(bolusString)
+                            .font(.caption)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                    }
+                    .padding(.leading, 5)
+
+                    Spacer()
+
+                    Button { state.cancelBolus() } label: {
+                        Image(systemName: "xmark.app")
+                            .font(.system(size: 25))
+                    }.tint(Color.tabBar)
+                        .buttonStyle(.borderless)
+                        .accessibilityLabel("Cancel bolus")
+                }
+                .padding(.horizontal, 10)
+                .padding(.trailing, 8)
+            }
+            .padding(.horizontal, 10)
+            .overlay(alignment: .bottom) {
+                BolusProgressBar(progress: progress)
+                    .padding(.horizontal, 18)
+                    .padding(.bottom, 1)
+            }
+            .clipShape(RoundedRectangle(cornerRadius: 15))
+        }
+
         private var taskButtonLabel: some View {
             if pumpBolusLimitExceeded {
                 return Text("Max Bolus of \(state.maxBolus.description) U Exceeded")
@@ -550,9 +641,8 @@ extension Treatments {
             let hasFatOrProtein = state.fat > 0 || state.protein > 0
             let bolusString = state.externalInsulin ? String(localized: "External Insulin") : String(localized: "Enact Bolus")
 
-            if state.isBolusInProgress && hasInsulin && !state.externalInsulin && (!hasCarbs || !hasFatOrProtein) {
-                return Text("Bolus In Progress...")
-            }
+            // Note: when a pump bolus is in progress, the row is rendered by `bolusInProgressView`
+            // (Home-style card), so this label's in-progress branch is intentionally absent.
 
             switch (hasInsulin, hasCarbs, hasFatOrProtein) {
             case (true, true, true):
@@ -562,7 +652,7 @@ extension Treatments {
             case (true, false, true):
                 return Text("Log FPU and \(bolusString)")
             case (true, false, false):
-                return Text(state.externalInsulin ? "Log External Insulin" : "Enact Bolus")
+                return Text(state.externalInsulin ? String(localized: "Log External Insulin") : String(localized: "Enact Bolus"))
             case (false, true, true):
                 return Text("Log Meal")
             case (false, true, false):

+ 27 - 0
Trio/Sources/Views/BolusProgressBar.swift

@@ -0,0 +1,27 @@
+import SwiftUI
+
+struct BolusProgressBar: View {
+    let progress: Decimal
+
+    var body: some View {
+        GeometryReader { geo in
+            RoundedRectangle(cornerRadius: 15)
+                .frame(height: 6)
+                .foregroundColor(.clear)
+                .background(
+                    LinearGradient(colors: [
+                        Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
+                        Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
+                        Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
+                        Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
+                        Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
+                    ], startPoint: .leading, endPoint: .trailing)
+                        .mask(alignment: .leading) {
+                            RoundedRectangle(cornerRadius: 15)
+                                .frame(width: geo.size.width * CGFloat(progress))
+                        }
+                )
+        }
+        .frame(height: 6)
+    }
+}