Procházet zdrojové kódy

Add Carbs Required as a configurable glucose alarm type

trioneer před 2 týdny
rodič
revize
6102131634

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -201,6 +201,7 @@
 		BD11796D2F4E22C100F90001 /* AddGlucoseAlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11796E2F4E22C100F90001 /* AddGlucoseAlertSheet.swift */; };
 		BD1179712F4E22C100F90001 /* AlarmEnumMenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */; };
 		BD1179732F4E22C100F90001 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179742F4E22C100F90001 /* AlarmBGSection.swift */; };
+		CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* AlarmGramsSection.swift */; };
 		BD1179772F4E22C100F90001 /* AlarmActiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */; };
 		BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */; };
 		BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */; };
@@ -1214,6 +1215,7 @@
 		BD11796E2F4E22C100F90001 /* AddGlucoseAlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGlucoseAlertSheet.swift; sourceTree = "<group>"; };
 		BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEnumMenuPicker.swift; sourceTree = "<group>"; };
 		BD1179742F4E22C100F90001 /* AlarmBGSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmBGSection.swift; sourceTree = "<group>"; };
+		CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = "<group>"; };
 		BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmActiveSection.swift; sourceTree = "<group>"; };
 		BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmAudioSection.swift; sourceTree = "<group>"; };
 		BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundCatalog.swift; sourceTree = "<group>"; };
@@ -4441,6 +4443,7 @@
 				BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */,
 				BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */,
 				BD1179742F4E22C100F90001 /* AlarmBGSection.swift */,
+				CA05000000000000000010C1 /* AlarmGramsSection.swift */,
 				BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */,
 				BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */,
 			);
@@ -5151,6 +5154,7 @@
 				BD11796D2F4E22C100F90001 /* AddGlucoseAlertSheet.swift in Sources */,
 				BD1179712F4E22C100F90001 /* AlarmEnumMenuPicker.swift in Sources */,
 				BD1179732F4E22C100F90001 /* AlarmBGSection.swift in Sources */,
+				CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */,
 				BD1179772F4E22C100F90001 /* AlarmActiveSection.swift in Sources */,
 				BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */,
 				BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */,

+ 27 - 2
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift

@@ -10,11 +10,30 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
     case low
     case forecastedLow
     case high
+    /// Driven by `Determination.carbsReq`, not by a glucose reading. Stored
+    /// alongside the other glucose alarms so the user has one place to
+    /// configure schedule/sound/snooze for everything fired by Trio.
+    case carbsRequired
 
     var id: String { rawValue }
 
     var priority: Int { Self.allCases.firstIndex(of: self) ?? 0 }
 
+    /// `true` when the alarm fires off a CGM glucose reading. `false` for
+    /// `forecastedLow` (driven by the determination forecast) and
+    /// `carbsRequired` (driven by the determination's `carbsReq` field).
+    var isReadingDriven: Bool {
+        switch self {
+        case .high,
+             .low,
+             .urgentLow:
+            return true
+        case .carbsRequired,
+             .forecastedLow:
+            return false
+        }
+    }
+
     /// Parses a glucose-alarm slug emitted by `GlucoseAlertCoordinator`
     /// (`glucose.<type>.<uuid>`). Returns nil for non-glucose alert
     /// identifiers — used by `BaseTrioAlertManager.requestSnooze` to decide
@@ -32,6 +51,7 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
         case .low: return String(localized: "Low Glucose")
         case .forecastedLow: return String(localized: "Low Glucose Soon")
         case .high: return String(localized: "High Glucose")
+        case .carbsRequired: return String(localized: "Carbs Required")
         }
     }
 
@@ -41,16 +61,19 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
         case .low: return String(localized: "Fires when glucose drops to or below a low threshold.")
         case .forecastedLow: return String(localized: "Fires when glucose is forecasted to be low within the next 20 minutes.")
         case .high: return String(localized: "Fires when glucose rises to or above a high threshold.")
+        case .carbsRequired: return String(localized: "Fires when oref recommends eating carbs to avoid a low.")
         }
     }
 
-    /// Default mg/dL threshold when adding a new alarm of this type.
+    /// Default threshold when adding a new alarm. Mg/dL for glucose types,
+    /// grams for `carbsRequired`.
     var defaultThresholdMgDL: Decimal {
         switch self {
         case .urgentLow: return 54
         case .low: return 72
         case .forecastedLow: return 72
         case .high: return 270
+        case .carbsRequired: return 10
         }
     }
 
@@ -61,6 +84,7 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
         case .low: return "trill.caf"
         case .forecastedLow: return "bloom.caf"
         case .high: return "chime.caf"
+        case .carbsRequired: return "bloop.caf"
         }
     }
 
@@ -70,7 +94,8 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
     var defaultOverridesSilenceAndDND: Bool {
         switch self {
         case .urgentLow: return true
-        case .forecastedLow,
+        case .carbsRequired,
+             .forecastedLow,
              .high,
              .low: return false
         }

+ 47 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift

@@ -0,0 +1,47 @@
+import SwiftUI
+
+/// Mirror of `AlarmBGSection` but for gram-valued thresholds (carbsRequired).
+/// Single fixed unit, no mg/dL ↔︎ mmol/L conversion.
+struct AlarmGramsSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Int>
+    let step: Int
+    @Binding var valueGrams: Decimal
+
+    @State private var showPicker = false
+
+    var body: some View {
+        Section(
+            header: Text(header),
+            footer: footer.map { Text($0) }
+        ) {
+            VStack(spacing: 0) {
+                HStack {
+                    Text(title)
+                    Spacer()
+                    Text("\(Int(NSDecimalNumber(decimal: valueGrams).intValue))")
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(String(localized: "g", comment: "Abbreviation for grams"))
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: Binding(
+                        get: { Int(NSDecimalNumber(decimal: valueGrams).intValue) },
+                        set: { valueGrams = Decimal($0) }
+                    )) {
+                        ForEach(Array(stride(from: range.lowerBound, through: range.upperBound, by: step)), id: \.self) { v in
+                            Text("\(v)").tag(v)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+}

+ 14 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -40,6 +40,7 @@ struct GlucoseAlertEditorView: View {
                 case .low: lowBody
                 case .forecastedLow: forecastedLowBody
                 case .high: highBody
+                case .carbsRequired: carbsRequiredBody
                 }
 
                 AlarmActiveSection(activeOption: $working.activeOption)
@@ -159,4 +160,17 @@ struct GlucoseAlertEditorView: View {
             valueMgDL: $working.thresholdMgDL
         )
     }
+
+    private var carbsRequiredBody: some View {
+        AlarmGramsSection(
+            header: String(localized: "Carbs Required Threshold"),
+            footer: String(
+                localized: "Fires when the algorithm suggests to eat at least this many grams of carbs to avoid a low."
+            ),
+            title: String(localized: "Carbs"),
+            range: 5 ... 50,
+            step: 1,
+            valueGrams: $working.thresholdMgDL
+        )
+    }
 }

+ 42 - 1
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -40,6 +40,7 @@ final class GlucoseAlertCoordinator: Injectable {
 
     /// Pure breach predicate. Low family (low/urgentLow/forecastedLow) breaches
     /// when the value is at or below threshold; high breaches at or above.
+    /// `carbsRequired` is determination-driven and evaluated separately.
     /// Extracted for unit testing — the instance evaluators call through here.
     static func breached(type: GlucoseAlertType, latestMgDL: Decimal, thresholdMgDL: Decimal) -> Bool {
         switch type {
@@ -49,6 +50,8 @@ final class GlucoseAlertCoordinator: Injectable {
             return latestMgDL <= thresholdMgDL
         case .high:
             return latestMgDL >= thresholdMgDL
+        case .carbsRequired:
+            return false
         }
     }
 
@@ -68,6 +71,8 @@ final class GlucoseAlertCoordinator: Injectable {
             return latestMgDL >= thresholdMgDL + recoveryMarginMgDL
         case .high:
             return latestMgDL <= thresholdMgDL - recoveryMarginMgDL
+        case .carbsRequired:
+            return false
         }
     }
 
@@ -153,7 +158,7 @@ final class GlucoseAlertCoordinator: Injectable {
         // would surface two in-app alerts for the same low event.
         let sorted = snapshot.sorted { $0.type.priority < $1.type.priority }
         var urgentLowFiring = false
-        for alarm in sorted where alarm.type != .forecastedLow {
+        for alarm in sorted where alarm.type.isReadingDriven {
             if alarm.type == .low, urgentLowFiring {
                 retractIfFiring(alarm)
                 continue
@@ -244,6 +249,35 @@ final class GlucoseAlertCoordinator: Injectable {
         }
     }
 
+    // MARK: - Carbs-required evaluation
+
+    /// Mirrors `evaluateForecast` for the carbs-required alarms. The alarm
+    /// threshold (grams, stored in `thresholdMgDL`) is independent from the
+    /// tab badge's threshold — surfaces are configured separately so the
+    /// user can have a low-threshold badge for awareness and a higher-
+    /// threshold alarm for active interruption.
+    private func evaluateCarbsRequired(_ determination: Determination) {
+        guard !isInLaunchQuietWindow else { return }
+        let snapshot = alertsSnapshot
+        let configuration = configurationSnapshot
+        let now = Date()
+        let carbsReq: Decimal? = determination.carbsReq
+
+        for alarm in snapshot where alarm.type == .carbsRequired {
+            guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
+                  isActive(alarm, at: now, configuration: configuration)
+            else {
+                retractIfFiring(alarm)
+                continue
+            }
+            guard let carbs = carbsReq, carbs >= alarm.thresholdMgDL else {
+                retractIfFiring(alarm)
+                continue
+            }
+            fireIfNeeded(alarm, valueMgDL: carbs)
+        }
+    }
+
     // MARK: - Issue / retract bookkeeping
 
     /// Called only from the evaluation queue (forecast + reading paths both
@@ -307,6 +341,7 @@ final class GlucoseAlertCoordinator: Injectable {
         case .low: typeSlug = "low"
         case .forecastedLow: typeSlug = "forecastedLow"
         case .high: typeSlug = "high"
+        case .carbsRequired: typeSlug = "carbsRequired"
         }
         return Alert.Identifier(
             managerIdentifier: BaseTrioAlertManager.managerIdentifier,
@@ -335,6 +370,11 @@ final class GlucoseAlertCoordinator: Injectable {
                 format: String(localized: "Glucose %1$@."),
                 valueString, limitString
             )
+        case .carbsRequired:
+            return String(
+                format: String(localized: "To prevent LOW required %d g of carbs"),
+                Int(NSDecimalNumber(decimal: valueMgDL).intValue)
+            )
         }
     }
 
@@ -384,6 +424,7 @@ extension GlucoseAlertCoordinator: DeterminationObserver {
     func determinationDidUpdate(_ determination: Determination) {
         evaluationQueue.async { [weak self] in
             self?.evaluateForecast(determination)
+            self?.evaluateCarbsRequired(determination)
         }
     }
 }

+ 14 - 2
Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift

@@ -25,7 +25,18 @@ final class GlucoseAlertsStore: ObservableObject {
         self.alertsKey = alertsKey
         self.configKey = configKey
         let loaded = Self.decode([GlucoseAlert].self, from: defaults, key: alertsKey) ?? []
-        alerts = loaded.isEmpty ? Self.defaultAlerts() : loaded
+        if loaded.isEmpty {
+            alerts = Self.defaultAlerts()
+        } else {
+            // Backfill alarm types added by later releases so upgrading users
+            // get a default-on entry instead of silently missing the type.
+            var migrated = loaded
+            let presentTypes = Set(loaded.map(\.type))
+            for type in GlucoseAlertType.allCases where !presentTypes.contains(type) {
+                migrated.append(GlucoseAlert(type: type))
+            }
+            alerts = migrated
+        }
         configuration = Self.decode(
             GlucoseAlertConfiguration.self,
             from: defaults,
@@ -45,7 +56,8 @@ final class GlucoseAlertsStore: ObservableObject {
             GlucoseAlert(type: .urgentLow),
             GlucoseAlert(type: .low),
             GlucoseAlert(type: .forecastedLow),
-            GlucoseAlert(type: .high)
+            GlucoseAlert(type: .high),
+            GlucoseAlert(type: .carbsRequired)
         ]
     }
 

+ 0 - 4
Trio/Sources/Services/Alerts/TrioAlertCategory.swift

@@ -27,7 +27,6 @@ enum TrioAlertCategory: Equatable {
     case glucoseDataStale
     case algorithmError
     case commsTransient
-    case carbsRequired
     case other(String)
 
     /// Whether an inbound alert in this category surfaces *immediately*.
@@ -42,7 +41,6 @@ enum TrioAlertCategory: Equatable {
              .batteryEmpty,
              .batteryLow,
              .bolusFailed,
-             .carbsRequired,
              .deliveryUncertain,
              .deviceExpirationReminder,
              .deviceExpired,
@@ -92,7 +90,6 @@ enum TrioAlertCategory: Equatable {
         case .glucoseDataStale: return "glucoseDataStale"
         case .algorithmError: return "algorithmError"
         case .commsTransient: return "commsTransient"
-        case .carbsRequired: return "carbsRequired"
         case let .other(id): return id
         }
     }
@@ -110,7 +107,6 @@ enum TrioAlertCategory: Equatable {
             return .critical
         case .batteryLow,
              .bolusFailed,
-             .carbsRequired,
              .deviceExpired,
              .glucoseDataStale,
              .glucoseForecastedLow,

+ 4 - 59
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -68,7 +68,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 .share()
                 .eraseToAnyPublisher()
 
-        broadcaster.register(DeterminationObserver.self, observer: self)
         broadcaster.register(alertMessageNotificationObserver.self, observer: self)
         Task { await updateGlucoseBadge() }
         configureNotificationCategories()
@@ -138,42 +137,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    private static let carbsRequiredAlertID = Alert.Identifier(
-        managerIdentifier: "trio.aps",
-        alertIdentifier: TrioAlertCategory.carbsRequired.alertIdentifier
-    )
-
-    private func notifyCarbsRequired(_ carbs: Int) {
-        guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold,
-              settingsManager.settings.showCarbsRequiredBadge
-        else {
-            trioAlertManager.retractAlert(identifier: Self.carbsRequiredAlertID)
-            return
-        }
-        let title = String(format: String(localized: "Carbs required: %d g", comment: "Carbs required"), carbs)
-        let body = String(
-            format: String(localized: "To prevent LOW required %d g of carbs", comment: "To prevent LOW required %d g of carbs"),
-            carbs
-        )
-        let content = Alert.Content(
-            title: title,
-            body: body,
-            acknowledgeActionButtonLabel: String(localized: "OK")
-        )
-        let alert = Alert(
-            identifier: Self.carbsRequiredAlertID,
-            foregroundContent: content,
-            backgroundContent: content,
-            trigger: .immediate,
-            interruptionLevel: TrioAlertCategory.carbsRequired.interruptionLevel
-        )
-        trioAlertManager.issueAlert(alert)
-    }
-
-    private func retractCarbsRequiredAlert() {
-        trioAlertManager.retractAlert(identifier: Self.carbsRequiredAlertID)
-    }
-
     /// Removes any `Trio.carbsRequiredNotification` UN still sitting in the
     /// system from a pre-pipeline install. Safe no-op when none exist.
     private func clearLegacyCarbsRequiredNotification() {
@@ -298,7 +261,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
 extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent) {
         let content = UNMutableNotificationContent()
-        var identifier: Identifier = .alertMessageNotification
+        // Pump / algorithm / glucose / carb subtypes used to route to dedicated
+        // UN identifiers — all of those have moved into the unified
+        // `TrioAlertManager` pipeline.
+        let identifier: Identifier = .alertMessageNotification
 
         if message.title == "" {
             switch message.type {
@@ -314,17 +280,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
         } else {
             content.title = message.title
         }
-        // Pump / algorithm / glucose subtypes used to route to dedicated UN
-        // identifiers (`pumpNotification`, `noLoopFirst/Second`, etc.) — all
-        // of those have moved into the unified `TrioAlertManager` pipeline.
-        // Only `.carb` keeps a dedicated identifier here so successive carb
-        // recommendations replace the previous one rather than stacking.
-        switch message.subtype {
-        case .carb:
-            identifier = .carbsRequiredNotification
-        default:
-            identifier = .alertMessageNotification
-        }
         switch message.action {
         case .snooze:
             content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
@@ -347,16 +302,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     }
 }
 
-extension BaseUserNotificationsManager: DeterminationObserver {
-    func determinationDidUpdate(_ determination: Determination) {
-        guard let carbsRequired = determination.carbsReq else {
-            retractCarbsRequiredAlert()
-            return
-        }
-        notifyCarbsRequired(Int(carbsRequired))
-    }
-}
-
 extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
     func userNotificationCenter(
         _: UNUserNotificationCenter,