Parcourir la source

Alerts: lock urgent-low + Critical tier as always-armed, glucose alerts on by default

Deniz Cengiz il y a 3 semaines
Parent
commit
9fdd461417

+ 2 - 2
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -84995,7 +84995,7 @@
         }
       }
     },
-    "Default notification banner. Suppressed by Silence and Focus Mode." : {
+    "Default notification banner. Suppressed by Silence & Focus Mode." : {
 
     },
     "Default Percent: 70%" : {
@@ -193803,7 +193803,7 @@
     "Overrides Silence & Focus Mode" : {
 
     },
-    "Overrides Silence & Focus Mode. Always audible. For situations requiring immediate attention." : {
+    "Overrides Silence & Focus Mode. For situations requiring immediate attention." : {
 
     },
     "Overview" : {

+ 2 - 4
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -25,13 +25,11 @@ enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
     var blurb: String {
         switch self {
         case .critical:
-            return String(
-                localized: "Overrides Silence & Focus Mode. Always audible. For situations requiring immediate attention."
-            )
+            return String(localized: "Overrides Silence & Focus Mode. For situations requiring immediate attention.")
         case .timeSensitive:
             return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode.")
         case .normal:
-            return String(localized: "Default notification banner. Suppressed by Silence and Focus Mode.")
+            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode.")
         }
     }
 

+ 8 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift

@@ -35,6 +35,14 @@ struct GlucoseAlert: Identifiable, Codable, Equatable {
         snoozedUntil = nil
     }
 
+    /// Whether the coordinator should fire this alarm when a reading breaches.
+    /// Urgent-low is the safety floor — the editor hides the Enabled toggle so
+    /// the user can't accidentally turn it off, and stored `isEnabled = false`
+    /// from a prior install is ignored here.
+    var shouldEvaluate: Bool {
+        type == .urgentLow || isEnabled
+    }
+
     // MARK: - Codable
 
     private enum CodingKeys: String, CodingKey {

+ 6 - 1
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -41,7 +41,12 @@ struct DeviceAlarmEditorView: View {
                             .font(.footnote)
                             .foregroundColor(.secondary)
                     }
-                    Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+                    // Critical-tier configs are always armed — the user can
+                    // mute the sound via the Audio section but not turn the
+                    // alarm itself off. Other tiers expose the toggle.
+                    if working.severity != .critical {
+                        Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+                    }
                     Toggle(
                         String(localized: "Override Silence & Focus Mode"),
                         isOn: $working.overridesSilenceAndDND

+ 5 - 1
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -92,7 +92,11 @@ struct GlucoseAlertEditorView: View {
             footer: Text(working.type.blurb)
         ) {
             TextField(String(localized: "Name"), text: $working.name)
-            Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+            // Urgent-low is the safety floor — the user can mute the sound
+            // from the Audio section but the alarm itself is always armed.
+            if working.type != .urgentLow {
+                Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+            }
             Toggle(
                 String(localized: "Override Silence & Focus Mode"),
                 isOn: $working.overridesSilenceAndDND

+ 9 - 1
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -57,7 +57,15 @@ final class DeviceAlertsStore: ObservableObject {
         at _: Date,
         isNight: Bool
     ) -> DeviceAlertSeverityConfig? {
-        let matching = configs.filter { $0.severity == severity && $0.isEnabled }
+        // Critical configs are always considered enabled — the editor hides
+        // the Enabled toggle on this tier, but legacy stored data may still
+        // carry `isEnabled = false` from a prior install. Honoring that
+        // flag here would silence the alarm despite the UI no longer
+        // exposing a way to re-enable it.
+        let matching = configs.filter { config in
+            guard config.severity == severity else { return false }
+            return severity == .critical || config.isEnabled
+        }
         let windowMatch = matching.first { config in
             switch config.activeOption {
             case .always: return false // .always is the fallback, prefer specific match

+ 2 - 2
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -109,7 +109,7 @@ final class GlucoseAlertCoordinator: Injectable {
         now: Date,
         configuration: GlucoseAlertConfiguration
     ) {
-        guard alarm.isEnabled, !isAlarmSnoozed(alarm, at: now),
+        guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
               isActive(alarm, at: now, configuration: configuration)
         else {
             retractIfFiring(alarm)
@@ -165,7 +165,7 @@ final class GlucoseAlertCoordinator: Injectable {
         now: Date,
         configuration: GlucoseAlertConfiguration
     ) {
-        guard alarm.isEnabled, !isAlarmSnoozed(alarm, at: now),
+        guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
               isActive(alarm, at: now, configuration: configuration),
               let result = ForecastedGlucoseEvaluator.evaluate(determination: determination)
         else {

+ 12 - 9
Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift

@@ -34,16 +34,19 @@ final class GlucoseAlertsStore: ObservableObject {
         bind()
     }
 
-    /// Enabled Low + High pair, urgent-low and forecasted-low disabled by
-    /// default. User can enable, edit thresholds, or add more entries.
+    /// Seed every glucose alarm enabled. Users running a stock CGM app for
+    /// low/high notifications can disable the duplicates per-alarm; the
+    /// safer default is to have Trio alert until the user opts out.
+    /// `urgentLow` cannot be disabled from the editor regardless — it's the
+    /// safety floor — but the stored flag is kept honest so the UI binding
+    /// stays simple.
     private static func defaultAlerts() -> [GlucoseAlert] {
-        var urgent = GlucoseAlert(type: .urgentLow)
-        urgent.isEnabled = false
-        var forecasted = GlucoseAlert(type: .forecastedLow)
-        forecasted.isEnabled = false
-        let low = GlucoseAlert(type: .low)
-        let high = GlucoseAlert(type: .high)
-        return [urgent, low, forecasted, high]
+        [
+            GlucoseAlert(type: .urgentLow),
+            GlucoseAlert(type: .low),
+            GlucoseAlert(type: .forecastedLow),
+            GlucoseAlert(type: .high)
+        ]
     }
 
     private func bind() {

+ 18 - 3
TrioTests/DeviceAlertsStoreTests.swift

@@ -68,19 +68,34 @@ import Testing
     }
 
     @Test("Disabled variants are skipped; falls back to next enabled") func disabledVariantSkipped() {
-        var dayDisabled = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
+        // Use timeSensitive — Critical configs are always considered enabled
+        // regardless of the stored isEnabled flag.
+        var dayDisabled = DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .day)
         dayDisabled.isEnabled = false
         dayDisabled.soundFilename = "disabled-day.caf"
-        var alwaysFallback = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
+        var alwaysFallback = DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always)
         alwaysFallback.soundFilename = "always.caf"
         let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
             alwaysFallback,
             dayDisabled,
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        let match = store.config(for: .timeSensitive, at: Date(), isNight: false)
+        #expect(match?.soundFilename == "always.caf")
+    }
+
+    @Test("Critical tier ignores stored isEnabled flag") func criticalAlwaysEnabled() {
+        var disabledCritical = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
+        disabledCritical.isEnabled = false
+        disabledCritical.soundFilename = "critical.caf"
+        let store = Self.makeStore(seed: [
+            disabledCritical,
             DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
             DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
         ])
         let match = store.config(for: .critical, at: Date(), isNight: false)
-        #expect(match?.soundFilename == "always.caf")
+        #expect(match?.soundFilename == "critical.caf")
     }
 
     @Test("All variants disabled returns nil") func allDisabledReturnsNil() {