Ver código fonte

Add history-based quick bolus via long-press on + button

Long-pressing the + tab bar button (0.5 s) queries the last 90 days of
manual boluses from CoreData and scores them with the scoring algorithm
from https://github.com/loopandlearn/LoopFollow/pull/603
(time-of-day Gaussian sigma=60 min, weekday/weekend factor,
10-day recency half-life). Up to five highest-scoring amounts are
offered in a confirmationDialog; selecting one requires Face/Touch ID
before handing off to the normal apsManager bolus pipeline. A short tap
still opens the treatment view as before. If no history passes the
minimum score threshold a plain-language message explains why no
suggestions are shown.

Thanks to @bjorkert for the original LoopFollow implementation.
Magnus Reintz 1 semana atrás
pai
commit
464cea0f9f

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

@@ -27217,6 +27217,9 @@
         }
       }
     },
+    "About Quick Bolus" : {
+      "comment" : "A button that displays a sheet with information about Quick Bolus."
+    },
     "About the Process" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -55324,6 +55327,9 @@
         }
       }
     },
+    "Based on your bolus history at this time of day" : {
+      "comment" : "A description of the quick bolus confirmation dialog."
+    },
     "Basic Insulin Rates & Targets" : {
       "localizations" : {
         "bg" : {
@@ -93006,6 +93012,12 @@
         }
       }
     },
+    "Deliver" : {
+
+    },
+    "Deliver %@ U?" : {
+      "comment" : "A button title that asks the user to confirm the amount of insulin to be delivered. The argument is the amount of insulin."
+    },
     "Delivery limits" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -137731,6 +137743,9 @@
         }
       }
     },
+    "How does this work?" : {
+      "comment" : "A button that shows a message explaining how the Quick Bolus feature works."
+    },
     "How it Works" : {
       "localizations" : {
         "bg" : {
@@ -180296,6 +180311,9 @@
     "No profile; cannot determine basal." : {
 
     },
+    "No Quick Bolus History Yet" : {
+      "comment" : "A message that appears when the user has not yet selected a quick bolus amount."
+    },
     "No recent oref algorithm determination." : {
       "localizations" : {
         "bg" : {
@@ -204784,6 +204802,15 @@
         }
       }
     },
+    "Quick Bolus" : {
+      "comment" : "A button that presents a quick bolus selection sheet."
+    },
+    "Quick Bolus learns from your manual boluses over time. Once you've delivered a few boluses, it will suggest amounts based on what you typically take at this time of day." : {
+      "comment" : "A description of how Quick Bolus works."
+    },
+    "Quick Bolus looks at your manual boluses from the past 90 days and suggests the amounts you most commonly take at this time of day.\n\nIt gives more weight to boluses from similar times of day, and treats weekdays and weekends separately. Older entries gradually count less.\n\nTap a suggestion to confirm and deliver it — your normal Face ID or Touch ID approval always applies." : {
+      "comment" : "A description of Quick Bolus."
+    },
     "Raise target glucose when Autosens Ratio is less than 1." : {
       "localizations" : {
         "bg" : {

+ 90 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -25,6 +25,7 @@ extension Home {
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
         @ObservationIgnored @Injected() var iobService: IOBService!
+        @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
@@ -120,6 +121,7 @@ extension Home {
         var shouldRunDeleteOnSettingsChange = true
 
         var showCarbsRequiredBadge: Bool = true
+        var quickBolusHistory: [Decimal] = []
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -534,6 +536,94 @@ extension Home {
             }
         }
 
+        func loadQuickBolusSuggestions() async {
+            let cutoff = Calendar.current.date(byAdding: .day, value: -90, to: Date()) ?? Date()
+            let predicate = NSPredicate(
+                format: "isSMB == false AND isExternal == false AND pumpEvent.timestamp >= %@",
+                cutoff as NSDate
+            )
+            do {
+                let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
+                    ofType: BolusStored.self,
+                    onContext: pumpHistoryFetchContext,
+                    predicate: predicate,
+                    key: "pumpEvent.timestamp",
+                    ascending: false,
+                    batchSize: 100
+                )
+
+                let suggestions: [Decimal] = await pumpHistoryFetchContext.perform {
+                    guard let boluses = results as? [BolusStored] else { return [] }
+
+                    let now = Date()
+                    let cal = Calendar.current
+                    let nowMinute = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
+                    let nowDOW = cal.component(.weekday, from: now)
+                    let sigma: Double = 60.0
+                    let halfLife: Double = 10.0
+
+                    var groups: [Decimal: Double] = [:]
+                    for bolus in boluses {
+                        guard let nsAmount = bolus.amount, nsAmount.doubleValue > 0,
+                              let timestamp = bolus.pumpEvent?.timestamp else { continue }
+
+                        var roundedKey = Decimal()
+                        var tempAmount = nsAmount as Decimal
+                        NSDecimalRound(&roundedKey, &tempAmount, 2, .plain)
+
+                        let entryMinute = cal.component(.hour, from: timestamp) * 60 + cal.component(.minute, from: timestamp)
+                        let entryDOW = cal.component(.weekday, from: timestamp)
+
+                        let diff = abs(entryMinute - nowMinute)
+                        let circularDiff = Double(min(diff, 1440 - diff))
+                        let t = exp(-(circularDiff * circularDiff) / (2.0 * sigma * sigma))
+
+                        let d: Double
+                        if entryDOW == nowDOW {
+                            d = 1.0
+                        } else {
+                            let nowWeekend = nowDOW == 1 || nowDOW == 7
+                            let entryWeekend = entryDOW == 1 || entryDOW == 7
+                            d = nowWeekend == entryWeekend ? 0.7 : 0.15
+                        }
+
+                        let daysAgo = now.timeIntervalSince(timestamp) / 86400.0
+                        let r = pow(0.5, daysAgo / halfLife)
+
+                        groups[roundedKey, default: 0] += t * d * r
+                    }
+
+                    return groups
+                        .filter { $0.value >= 0.1 }
+                        .sorted { $0.value > $1.value }
+                        .prefix(5)
+                        .map(\.key)
+                }
+
+                await MainActor.run {
+                    quickBolusHistory = suggestions
+                }
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) failed to fetch quick bolus history: \(error)")
+            }
+        }
+
+        func enactQuickBolus(amount: Decimal) async {
+            guard amount > 0 else { return }
+            let delivery = min(
+                Double(truncating: amount as NSDecimalNumber),
+                pumpInitialSettings.maxBolusUnits
+            )
+            do {
+                let authenticated = try await unlockmanager.unlock()
+                if authenticated {
+                    await apsManager.enactBolus(amount: delivery, isSMB: false, callback: nil)
+                }
+            } catch {
+                debug(.bolusState, "Quick bolus authentication error: \(error)")
+            }
+        }
+
         func addPump(_ type: PumpConfig.PumpType) {
             setupPumpType = type
             shouldDisplayPumpSetupSheet = true

+ 100 - 21
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -32,6 +32,11 @@ extension Home {
         @State var isMenuPresented = false
         @State var showTreatments = false
         @State var selectedTab: Int = 0
+        @State var showQuickBolusPicker = false
+        @State var quickBolusAmount: Decimal = 0
+        @State var showQuickBolusConfirm = false
+        @State var showQuickBolusNoHistory = false
+        @State var showQuickBolusInfo = false
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
         @State var notificationsDisabled = false
@@ -139,18 +144,18 @@ extension Home {
                 cgmSensorExpiresAt: state.cgmSensorExpiresAt,
                 cgmWarmupEndsAt: state.cgmWarmupEndsAt
             )
-                .onTapGesture {
-                    if !state.cgmAvailable {
-                        showCGMSelection.toggle()
-                    } else {
-                        state.shouldDisplayCGMSetupSheet.toggle()
-                    }
-                }
-                .onLongPressGesture {
-                    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                    impactHeavy.impactOccurred()
-                    state.showModal(for: .snooze)
+            .onTapGesture {
+                if !state.cgmAvailable {
+                    showCGMSelection.toggle()
+                } else {
+                    state.shouldDisplayCGMSetupSheet.toggle()
                 }
+            }
+            .onLongPressGesture {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+                state.showModal(for: .snooze)
+            }
         }
 
         var pumpView: some View {
@@ -1109,17 +1114,25 @@ extension Home {
                 }
                 .tint(Color.tabBar)
 
-                Button(
-                    action: {
-                        state.showModal(for: .treatmentView) },
-                    label: {
-                        Image(systemName: "plus.circle.fill")
-                            .font(.system(size: 40))
-                            .foregroundStyle(Color.tabBar)
-                            .padding(.vertical, 2)
-                            .padding(.horizontal, 24)
+                Image(systemName: "plus.circle.fill")
+                    .font(.system(size: 40))
+                    .foregroundStyle(Color.tabBar)
+                    .padding(.vertical, 2)
+                    .padding(.horizontal, 24)
+                    .contentShape(Rectangle())
+                    .onTapGesture {
+                        state.showModal(for: .treatmentView)
+                    }
+                    .onLongPressGesture(minimumDuration: 0.5) {
+                        Task {
+                            await state.loadQuickBolusSuggestions()
+                            if state.quickBolusHistory.isEmpty {
+                                showQuickBolusNoHistory = true
+                            } else {
+                                showQuickBolusPicker = true
+                            }
+                        }
                     }
-                )
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
                 .onChange(of: selectedTab) {
                     if !settingsPath.isEmpty {
@@ -1128,6 +1141,19 @@ extension Home {
                 }
         }
 
+        private func formatBolusLabel(_ amount: Decimal) -> String {
+            let formatted = Formatter.bolusFormatter.string(from: amount as NSDecimalNumber) ?? amount.description
+            return "\(formatted) U"
+        }
+
+        private func formatBolusConfirmTitle(_ amount: Decimal) -> String {
+            let formatted = Formatter.bolusFormatter.string(from: amount as NSDecimalNumber) ?? amount.description
+            return String(
+                format: String(localized: "Deliver %@ U?", comment: "Quick bolus confirmation dialog title; %@ is the formatted bolus amount"),
+                formatted
+            )
+        }
+
         var body: some View {
             ZStack(alignment: .center) {
                 tabBar()
@@ -1136,6 +1162,59 @@ extension Home {
                     CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
                 }
             }
+            .confirmationDialog(
+                String(localized: "Quick Bolus", comment: "Title of the quick bolus picker sheet"),
+                isPresented: $showQuickBolusPicker,
+                titleVisibility: .visible
+            ) {
+                Button(String(localized: "How does this work?", comment: "Quick bolus info button")) {
+                    showQuickBolusInfo = true
+                }
+                ForEach(state.quickBolusHistory, id: \.self) { amount in
+                    Button(formatBolusLabel(amount)) {
+                        quickBolusAmount = amount
+                        showQuickBolusConfirm = true
+                    }
+                }
+                Button(String(localized: "Cancel"), role: .cancel) {}
+            } message: {
+                Text(String(
+                    localized: "Based on your bolus history at this time of day",
+                    comment: "Subtitle of the quick bolus picker sheet"
+                ))
+            }
+            .confirmationDialog(
+                formatBolusConfirmTitle(quickBolusAmount),
+                isPresented: $showQuickBolusConfirm,
+                titleVisibility: .visible
+            ) {
+                Button(String(localized: "Deliver", comment: "Quick bolus confirm button"), role: .destructive) {
+                    Task { await state.enactQuickBolus(amount: quickBolusAmount) }
+                }
+                Button(String(localized: "Cancel"), role: .cancel) {}
+            }
+            .alert(
+                String(localized: "No Quick Bolus History Yet", comment: "Alert title when no quick bolus history exists"),
+                isPresented: $showQuickBolusNoHistory
+            ) {
+                Button(String(localized: "OK"), role: .cancel) {}
+            } message: {
+                Text(String(
+                    localized: "Quick Bolus learns from your manual boluses over time. Once you've delivered a few boluses, it will suggest amounts based on what you typically take at this time of day.",
+                    comment: "Alert body explaining that quick bolus history is empty"
+                ))
+            }
+            .alert(
+                String(localized: "About Quick Bolus", comment: "Info alert title for quick bolus feature"),
+                isPresented: $showQuickBolusInfo
+            ) {
+                Button(String(localized: "OK"), role: .cancel) {}
+            } message: {
+                Text(String(
+                    localized: "Quick Bolus looks at your manual boluses from the past 90 days and suggests the amounts you most commonly take at this time of day.\n\nIt gives more weight to boluses from similar times of day, and treats weekdays and weekends separately. Older entries gradually count less.\n\nTap a suggestion to confirm and deliver it — your normal Face ID or Touch ID approval always applies.",
+                    comment: "Info alert body explaining how quick bolus scoring works"
+                ))
+            }
         }
     }
 }