Quellcode durchsuchen

Merge branch 'dev' of github.com:nightscout/Trio into core-data-fixes

# Conflicts:
#	OmnipodKit
#	Trio.xcodeproj/project.pbxproj
#	Trio/Sources/Localizations/Main/Localizable.xcstrings
#	Trio/Sources/Modules/Home/HomeStateModel.swift
Marvin Polscheit vor 10 Stunden
Ursprung
Commit
bf16111a65
100 geänderte Dateien mit 12283 neuen und 1421 gelöschten Zeilen
  1. 3 0
      .gitmodules
  2. 1 1
      Config.xcconfig
  3. 1 0
      EversenseKit
  4. 5 1
      LiveActivity/LiveActivity.swift
  5. 97 4
      LiveActivity/Views/LiveActivityChartView.swift
  6. 1 1
      OmnipodKit
  7. 402 93
      Trio.xcodeproj/project.pbxproj
  8. 3 0
      Trio.xcworkspace/contents.xcworkspacedata
  9. 0 9
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  10. BIN
      Trio/Resources/Sounds/alarm.caf
  11. BIN
      Trio/Resources/Sounds/bloom.caf
  12. BIN
      Trio/Resources/Sounds/bloop.caf
  13. BIN
      Trio/Resources/Sounds/bright_alarm.caf
  14. BIN
      Trio/Resources/Sounds/chime.caf
  15. BIN
      Trio/Resources/Sounds/clear_chimes.caf
  16. BIN
      Trio/Resources/Sounds/critical.caf
  17. BIN
      Trio/Resources/Sounds/crying.caf
  18. BIN
      Trio/Resources/Sounds/dings.caf
  19. BIN
      Trio/Resources/Sounds/high_chimes.caf
  20. BIN
      Trio/Resources/Sounds/honk.caf
  21. BIN
      Trio/Resources/Sounds/minimal.caf
  22. BIN
      Trio/Resources/Sounds/mood_synth.caf
  23. BIN
      Trio/Resources/Sounds/simple.caf
  24. BIN
      Trio/Resources/Sounds/spring.caf
  25. BIN
      Trio/Resources/Sounds/synth.caf
  26. BIN
      Trio/Resources/Sounds/trill.caf
  27. BIN
      Trio/Resources/Sounds/urgent_low.caf
  28. 2 0
      Trio/Resources/Trio.entitlements
  29. 166 25
      Trio/Sources/APS/APSManager.swift
  30. 32 8
      Trio/Sources/APS/CGM/PluginSource.swift
  31. 20 51
      Trio/Sources/APS/DeviceDataManager.swift
  32. 5 0
      Trio/Sources/APS/FetchGlucoseManager.swift
  33. 60 54
      Trio/Sources/APS/OpenAPS/OpenAPS.swift
  34. 1 1
      Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift
  35. 6 0
      Trio/Sources/APS/PluginManager.swift
  36. 3 3
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  37. 3 0
      Trio/Sources/Application/TrioApp.swift
  38. 6 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  39. 1 0
      Trio/Sources/Helpers/CGMOptions.swift
  40. 1 1
      Trio/Sources/Helpers/Formatters.swift
  41. 29 0
      Trio/Sources/Helpers/TimeInterval+Convenience.swift
  42. 8733 207
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  43. 1 47
      Trio/Sources/Logger/Logger.swift
  44. 7 0
      Trio/Sources/Models/BolusStatus.swift
  45. 0 2
      Trio/Sources/Models/DecimalPickerSettings.swift
  46. 34 0
      Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift
  47. 33 0
      Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift
  48. 79 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift
  49. 63 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift
  50. 77 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift
  51. 63 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift
  52. 103 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift
  53. 0 27
      Trio/Sources/Models/GlucoseNotificationsOption.swift
  54. 10 0
      Trio/Sources/Models/GlucoseSourceKey.swift
  55. 11 9
      Trio/Sources/Models/NotificationIdentifiers.swift
  56. 13 49
      Trio/Sources/Models/TrioSettings.swift
  57. 7 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift
  58. 5 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift
  59. 18 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift
  60. 135 0
      Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift
  61. 3 3
      Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift
  62. 7 8
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  63. 31 10
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  64. 27 1
      Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift
  65. 7 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift
  66. 5 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift
  67. 8 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift
  68. 65 0
      Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift
  69. 129 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift
  70. 226 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift
  71. 7 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift
  72. 5 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift
  73. 36 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift
  74. 87 0
      Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift
  75. 23 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift
  76. 168 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift
  77. 54 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift
  78. 39 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift
  79. 47 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift
  80. 44 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift
  81. 191 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift
  82. 345 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  83. 0 5
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift
  84. 0 3
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift
  85. 0 53
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  86. 0 389
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  87. 3 0
      Trio/Sources/Modules/Home/HomeProvider.swift
  88. 110 1
      Trio/Sources/Modules/Home/HomeStateModel.swift
  89. 31 26
      Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift
  90. 1 1
      Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift
  91. 59 18
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  92. 35 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesInfoView.swift
  93. 149 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesView.swift
  94. 58 0
      Trio/Sources/Modules/Home/View/QuickPickBoluses/SlideToConfirmView.swift
  95. 2 0
      Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift
  96. 34 0
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift
  97. 2 2
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  98. 0 307
      Trio/Sources/Modules/Main/MainStateModel.swift
  99. 5 1
      Trio/Sources/Modules/Main/View/MainRootView.swift
  100. 0 0
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

+ 3 - 0
.gitmodules

@@ -31,3 +31,6 @@
 [submodule "OmnipodKit"]
 [submodule "OmnipodKit"]
 	path = OmnipodKit
 	path = OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
+[submodule "EversenseKit"]
+	path = EversenseKit
+	url = https://github.com/loopandlearn/EversenseKit

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 
 // The developers set the version numbers, please leave them alone
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.8.4
 APP_VERSION = 0.8.4
-APP_DEV_VERSION = 0.8.4.2
+APP_DEV_VERSION = 0.8.4.12
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 
 

+ 1 - 0
EversenseKit

@@ -0,0 +1 @@
+Subproject commit 66979665bc73137b0a70e9b4f6f88f21558fa4c9

+ 5 - 1
LiveActivity/LiveActivity.swift

@@ -126,7 +126,11 @@ private extension LiveActivityAttributes.ContentState {
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDuration: 60,
             tempTargetDuration: 60,
             tempTargetTarget: 120,
             tempTargetTarget: 120,
-            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems
+            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
+            minForecast: [],
+            maxForecast: [],
+            forecastLines: [],
+            forecastDisplayType: "cone"
         )
         )
 
 
     // 0 is the widest digit. Use this to get an upper bound on text width.
     // 0 is the widest digit. Use this to get an upper bound on text width.

+ 97 - 4
LiveActivity/Views/LiveActivityChartView.swift

@@ -9,6 +9,11 @@ import Foundation
 import SwiftUI
 import SwiftUI
 import WidgetKit
 import WidgetKit
 
 
+private enum ForecastDisplayTypeKey {
+    static let lines = "lines"
+    static let cone = "cone"
+}
+
 struct LiveActivityChartView: View {
 struct LiveActivityChartView: View {
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.isWatchOS) var isWatchOS
     @Environment(\.isWatchOS) var isWatchOS
@@ -22,9 +27,13 @@ struct LiveActivityChartView: View {
 
 
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
 
 
-        // Determine scale
-        let minValue = min(additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39, 39)
-        let maxValue = max(additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold, maxThreshhold)
+        // Determine scale, accounting for both glucose history and prediction values
+        let chartMin = additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39
+        let chartMax = additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold
+        let forecastMin = additionalState.minForecast.min().map { Decimal($0) } ?? chartMin
+        let forecastMax = min(additionalState.maxForecast.max().map { Decimal($0) } ?? chartMax, maxThreshhold)
+        let minValue = min(min(chartMin, forecastMin), 39)
+        let maxValue = max(max(chartMax, forecastMax), maxThreshhold)
 
 
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
             .asMmolL
             .asMmolL
@@ -34,12 +43,22 @@ struct LiveActivityChartView: View {
 
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
 
         let calendar = Calendar.current
         let calendar = Calendar.current
         let now = Date()
         let now = Date()
 
 
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
-        let endDate = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+        let endDate: Date = {
+            let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+            guard hasForecast, let anchorDate = state.date else { return baseEnd }
+            let forecastCount = max(
+                additionalState.minForecast.count,
+                additionalState.forecastLines.max(by: { $0.values.count < $1.values.count })?.values.count ?? 0
+            )
+            let predictionEnd = anchorDate.addingTimeInterval(TimeInterval(forecastCount * 300))
+            return max(baseEnd, predictionEnd)
+        }()
 
 
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -83,6 +102,14 @@ struct LiveActivityChartView: View {
                 drawActiveTempTarget()
                 drawActiveTempTarget()
             }
             }
 
 
+            if hasForecast, let anchorDate = state.date {
+                if additionalState.forecastDisplayType == ForecastDisplayTypeKey.lines {
+                    drawForecastLines(anchorDate: anchorDate, isMgdL: isMgdL)
+                } else {
+                    drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
+                }
+            }
+
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
         }
         }
         .chartYAxis {
         .chartYAxis {
@@ -149,6 +176,72 @@ struct LiveActivityChartView: View {
         .lineStyle(.init(lineWidth: 8))
         .lineStyle(.init(lineWidth: 8))
     }
     }
 
 
+    private func timeForIndex(_ index: Int, anchorDate: Date) -> Date {
+        anchorDate.addingTimeInterval(TimeInterval(index * 300))
+    }
+
+    private func drawForecastCone(anchorDate: Date, isMgdL: Bool, maxValue: Decimal) -> some ChartContent {
+        let minForecast = additionalState.minForecast
+        let maxForecast = additionalState.maxForecast
+        let cappedMax = isMgdL ? maxValue : maxValue.asMmolL
+        let count = min(minForecast.count, maxForecast.count)
+
+        // Pre-compute cone data to avoid conditionals inside the ForEach closure
+        let coneData: [(date: Date, yMin: Decimal, yMax: Decimal)] = (0 ..< count).map { index in
+            let xValue = timeForIndex(index, anchorDate: anchorDate)
+            let delta = minForecast[index] - maxForecast[index]
+            let yMin: Decimal
+            let yMax: Decimal
+            if delta == 0 {
+                let base = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMin = base - 1
+                yMax = base + 1
+            } else {
+                yMin = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMax = isMgdL ? Decimal(maxForecast[index]) : Decimal(maxForecast[index]).asMmolL
+            }
+            return (date: xValue, yMin: min(yMin, cappedMax), yMax: min(yMax, cappedMax))
+        }
+
+        return ForEach(coneData.indices, id: \.self) { i in
+            AreaMark(
+                x: .value("Time", coneData[i].date),
+                yStart: .value("Min", coneData[i].yMin),
+                yEnd: .value("Max", coneData[i].yMax)
+            )
+            .foregroundStyle(Color.blue.opacity(0.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
+    private func drawForecastLines(anchorDate: Date, isMgdL: Bool) -> some ChartContent {
+        let colorMap: [String: Color] = [
+            "iob": Color(red: 0.118, green: 0.588, blue: 0.988),
+            "cob": Color.orange,
+            "uam": Color(red: 0.820, green: 0.169, blue: 0.969),
+            "zt": Color(red: 0.443, green: 0.380, blue: 0.937)
+        ]
+
+        let points: [(series: String, date: Date, value: Decimal)] = additionalState.forecastLines.flatMap { line in
+            line.values.enumerated().map { index, value in
+                let displayValue = isMgdL ? Decimal(value) : Decimal(value).asMmolL
+                return (series: line.type, date: timeForIndex(index, anchorDate: anchorDate), value: displayValue)
+            }
+        }
+
+        return ForEach(0 ..< points.count, id: \.self) { i in
+            let point = points[i]
+            LineMark(
+                x: .value("Time", point.date),
+                y: .value("Value", point.value),
+                series: .value("Type", point.series)
+            )
+            .foregroundStyle(colorMap[point.series] ?? Color.gray)
+            .lineStyle(.init(lineWidth: 1.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
         let hardCodedLow = Decimal(55)
         let hardCodedLow = Decimal(55)

+ 1 - 1
OmnipodKit

@@ -1 +1 @@
-Subproject commit 4d254498cf016806b3e0ec33dd5fbfdf6e2135a6
+Subproject commit b5b95562f73e6d3627e3e20ffe31f8d7f6a4639e

Datei-Diff unterdrückt, da er zu groß ist
+ 402 - 93
Trio.xcodeproj/project.pbxproj


+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

@@ -35,6 +35,9 @@
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
    </FileRef>
    </FileRef>
    <FileRef
    <FileRef
+      location = "group:EversenseKit/EversenseKit.xcodeproj">
+   </FileRef>
+   <FileRef
       location = "group:TidepoolService/TidepoolService.xcodeproj">
       location = "group:TidepoolService/TidepoolService.xcodeproj">
    </FileRef>
    </FileRef>
 </Workspace>
 </Workspace>

+ 0 - 9
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -254,15 +254,6 @@
       }
       }
     },
     },
     {
     {
-      "identity" : "swiftmessages",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/SwiftKickMobile/SwiftMessages",
-      "state" : {
-        "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40",
-        "version" : "9.0.9"
-      }
-    },
-    {
       "identity" : "swinject",
       "identity" : "swinject",
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",
       "location" : "https://github.com/Swinject/Swinject",

BIN
Trio/Resources/Sounds/alarm.caf


BIN
Trio/Resources/Sounds/bloom.caf


BIN
Trio/Resources/Sounds/bloop.caf


BIN
Trio/Resources/Sounds/bright_alarm.caf


BIN
Trio/Resources/Sounds/chime.caf


BIN
Trio/Resources/Sounds/clear_chimes.caf


BIN
Trio/Resources/Sounds/critical.caf


BIN
Trio/Resources/Sounds/crying.caf


BIN
Trio/Resources/Sounds/dings.caf


BIN
Trio/Resources/Sounds/high_chimes.caf


BIN
Trio/Resources/Sounds/honk.caf


BIN
Trio/Resources/Sounds/minimal.caf


BIN
Trio/Resources/Sounds/mood_synth.caf


BIN
Trio/Resources/Sounds/simple.caf


BIN
Trio/Resources/Sounds/spring.caf


BIN
Trio/Resources/Sounds/synth.caf


BIN
Trio/Resources/Sounds/trill.caf


BIN
Trio/Resources/Sounds/urgent_low.caf


+ 2 - 0
Trio/Resources/Trio.entitlements

@@ -14,6 +14,8 @@
 	<array>
 	<array>
 		<string>TAG</string>
 		<string>TAG</string>
 	</array>
 	</array>
+	<key>com.apple.developer.usernotifications.time-sensitive</key>
+	<true/>
 	<key>com.apple.security.application-groups</key>
 	<key>com.apple.security.application-groups</key>
 	<array>
 	<array>
 		<string>$(APP_GROUP_ID)</string>
 		<string>$(APP_GROUP_ID)</string>

+ 166 - 25
Trio/Sources/APS/APSManager.swift

@@ -8,6 +8,11 @@ import Swinject
 
 
 protocol APSManager {
 protocol APSManager {
     func heartbeat(date: Date)
     func heartbeat(date: Date)
+    /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
+    /// Surfaces transient errors immediately instead of waiting for the
+    /// usual dwell threshold — when the user explicitly asks for a loop,
+    /// they want feedback even if the underlying error is "transient".
+    func markNextLoopUserInitiated()
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     var pumpManager: PumpManagerUI? { get set }
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager? { get }
     var bluetoothManager: BluetoothStateManager? { get }
@@ -36,6 +41,13 @@ protocol APSManager {
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 }
 
 
+/// Notified after a bolus-related failure so observing UI (e.g. the
+/// treatment screen's bolus state) can clean up state. Broadcast by
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
+protocol BolusFailureObserver {
+    func bolusDidFail()
+}
+
 enum APSError: LocalizedError {
 enum APSError: LocalizedError {
     case pumpError(Error)
     case pumpError(Error)
     case invalidPumpState(message: String)
     case invalidPumpState(message: String)
@@ -57,17 +69,6 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
         }
     }
     }
-
-    static func pumpErrorMatches(message: String) -> Bool {
-        message.contains(String(localized: "Pump Error"))
-    }
-
-    static func pumpWarningMatches(message: String) -> Bool {
-        message.contains(String(localized: "Invalid Pump State")) || message
-            .contains("PumpMessage") || message
-            .contains("PumpOpsError") || message.contains("RileyLink") || message
-            .contains(String(localized: "Pump did not respond in time"))
-    }
 }
 }
 
 
 // MARK: - Thread-safe loop serialization
 // MARK: - Thread-safe loop serialization
@@ -104,6 +105,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
         didSet {
         didSet {
@@ -180,7 +182,7 @@ final class BaseAPSManager: APSManager, Injectable {
             if wasParsed {
             if wasParsed {
                 Task {
                 Task {
                     do {
                     do {
-                        try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+                        try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
                     } catch {
                     } catch {
                         debug(
                         debug(
                             .apsManager,
                             .apsManager,
@@ -209,10 +211,12 @@ final class BaseAPSManager: APSManager, Injectable {
 
 
         deviceDataManager.bolusTrigger
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
             .receive(on: processQueue)
-            .sink { [weak self] bolusing in
-                if bolusing {
+            .sink { [weak self] bolusState in
+                switch bolusState {
+                case .initiating,
+                     .inProgress:
                     self?.createBolusReporter()
                     self?.createBolusReporter()
-                } else {
+                case .noBolus:
                     self?.clearBolusReporter()
                     self?.clearBolusReporter()
                 }
                 }
             }
             }
@@ -259,6 +263,24 @@ final class BaseAPSManager: APSManager, Injectable {
         Task { [weak self] in
         Task { [weak self] in
             guard let self else { return }
             guard let self else { return }
 
 
+            // Consume the user-initiated flag unconditionally — it was set
+            // for the loop the user just triggered. If the guards below block
+            // (suspended, too-soon, no pump), the next scheduled tick must
+            // not inherit it and bypass dwell suppression for an error the
+            // user didn't request.
+            let userInitiated = self.nextLoopUserInitiated
+            self.nextLoopUserInitiated = false
+
+            // Don't try to run a loop while pump setup / pod pairing is in
+            // progress — `verifyStatus` would throw `invalidPumpState("Pump
+            // not set")` and surface a modal banner on top of the pod
+            // activation sheet, closing the sheet (reported by tester during
+            // O5 pairing).
+            guard self.pumpManager != nil else {
+                debug(.apsManager, "No pump manager — skipping loop attempt")
+                return
+            }
+
             // Atomic check-and-set via actor — eliminates the race between
             // Atomic check-and-set via actor — eliminates the race between
             // checking isLooping.value and sending isLooping(true).
             // checking isLooping.value and sending isLooping(true).
             guard await loopGuard.tryStart(
             guard await loopGuard.tryStart(
@@ -270,6 +292,11 @@ final class BaseAPSManager: APSManager, Injectable {
                 return
                 return
             }
             }
 
 
+            // Affects whether transient errors surface immediately instead of
+            // dwell-suppressed (see `surfaceErrorIfNeeded`).
+            self.currentLoopUserInitiated = userInitiated
+            defer { self.currentLoopUserInitiated = false }
+
             // Start background task
             // Start background task
             // we probably need to refactor this when implementing Swift 6 due to mutation of a captured var in an async context
             // we probably need to refactor this when implementing Swift 6 due to mutation of a captured var in an async context
             var taskID: UIBackgroundTaskIdentifier = .invalid
             var taskID: UIBackgroundTaskIdentifier = .invalid
@@ -370,6 +397,8 @@ final class BaseAPSManager: APSManager, Injectable {
             debug(.apsManager, "Loop succeeded")
             debug(.apsManager, "Loop succeeded")
             lastLoopDate = Date()
             lastLoopDate = Date()
             lastError.send(nil)
             lastError.send(nil)
+            transientCategoryFirstSeen.removeAll()
+            transientCategoryCount.removeAll()
         }
         }
 
 
         loopStats(loopStatRecord: loopStatRecord)
         loopStats(loopStatRecord: loopStatRecord)
@@ -407,7 +436,7 @@ final class BaseAPSManager: APSManager, Injectable {
         else {
         else {
             let result = try await openAPS.autosense(
             let result = try await openAPS.autosense(
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref
+                useJavascriptOref: settings.useJavascriptOref
             )
             )
             return result != nil
             return result != nil
         }
         }
@@ -486,14 +515,14 @@ final class BaseAPSManager: APSManager, Injectable {
             let now = Date()
             let now = Date()
 
 
             // put profile creation up front since autosens needs it
             // put profile creation up front since autosens needs it
-            try await openAPS.createProfiles(useSwiftOref: settings.useSwiftOref)
+            try await openAPS.createProfiles(useJavascriptOref: settings.useJavascriptOref)
             let currentTemp = try await fetchCurrentTempBasal(date: now)
             let currentTemp = try await fetchCurrentTempBasal(date: now)
             _ = try await autosense()
             _ = try await autosense()
 
 
             let determination = try await openAPS.determineBasal(
             let determination = try await openAPS.determineBasal(
                 currentTemp: currentTemp,
                 currentTemp: currentTemp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: now
                 clock: now
             )
             )
             iobFileDidUpdate.send(())
             iobFileDidUpdate.send(())
@@ -540,7 +569,7 @@ final class BaseAPSManager: APSManager, Injectable {
             return try await openAPS.determineBasal(
             return try await openAPS.determineBasal(
                 currentTemp: temp,
                 currentTemp: temp,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
-                useSwiftOref: settings.useSwiftOref,
+                useJavascriptOref: settings.useJavascriptOref,
                 clock: Date(),
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,
                 simulatedBolusAmount: simulatedBolusAmount,
@@ -606,9 +635,14 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             }
         } catch {
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error)")
             warning(.apsManager, "Bolus failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus failed"),
+                body: String(localized: "Check pump history before repeating.")
+                    + "\n\n\(error.localizedDescription)"
+            )
             if !isSMB {
             if !isSMB {
-                // Use MainActor to handle broadcaster notification
                 let broadcaster = self.broadcaster
                 let broadcaster = self.broadcaster
                 Task { @MainActor in
                 Task { @MainActor in
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
@@ -632,7 +666,12 @@ final class BaseAPSManager: APSManager, Injectable {
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus cancellation failed"),
+                body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
+            )
             callback?(
             callback?(
                 false,
                 false,
                 String(
                 String(
@@ -724,10 +763,9 @@ final class BaseAPSManager: APSManager, Injectable {
             throw APSError.apsError(message: "Pump not set")
             throw APSError.apsError(message: "Pump not set")
         }
         }
 
 
-        // Check if pump is suspended and abort if it is
         if pump.status.pumpStatus.suspended {
         if pump.status.pumpStatus.suspended {
-            info(.apsManager, "Skipping enactDetermination because pump is suspended")
-            return // return without throwing an error
+            debug(.apsManager, "Skipping enactDetermination because pump is suspended")
+            return
         }
         }
 
 
         // Unable to do temp basal during manual temp basal 😁
         // Unable to do temp basal during manual temp basal 😁
@@ -1239,9 +1277,108 @@ final class BaseAPSManager: APSManager, Injectable {
         }
         }
     }
     }
 
 
+    private var transientCategoryFirstSeen: [String: Date] = [:]
+    private var transientCategoryCount: [String: Int] = [:]
+    private static let transientDwellThreshold: TimeInterval = 60
+    private static let transientCountThreshold = 2
+
+    /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
+    /// on the next entry into `loop()` so that errors during a user-initiated
+    /// loop surface immediately instead of being suppressed by dwell logic.
+    @SyncAccess private var nextLoopUserInitiated: Bool = false
+    private var currentLoopUserInitiated: Bool = false
+
+    func markNextLoopUserInitiated() {
+        nextLoopUserInitiated = true
+    }
+
     private func processError(_ error: Error) {
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         warning(.apsManager, "\(error)")
         lastError.send(error)
         lastError.send(error)
+        surfaceErrorIfNeeded(error)
+    }
+
+    private func surfaceErrorIfNeeded(_ error: Error) {
+        let category = TrioAlertClassifier.categorize(error: error)
+        let key = String(describing: category)
+
+        if category.shouldFireImmediately || currentLoopUserInitiated {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+            return
+        }
+
+        let now = Date()
+        let firstSeen = transientCategoryFirstSeen[key] ?? now
+        let count = (transientCategoryCount[key] ?? 0) + 1
+        let dwellElapsed = now.timeIntervalSince(firstSeen)
+        let dwellMet = dwellElapsed >= Self.transientDwellThreshold
+        let countMet = count >= Self.transientCountThreshold
+
+        if dwellMet || countMet {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+        } else {
+            transientCategoryFirstSeen[key] = firstSeen
+            transientCategoryCount[key] = count
+            debug(
+                .apsManager,
+                "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
+            )
+        }
+    }
+
+    private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
+        let (title, body) = describeForAlert(error)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: "OK"
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func describeForAlert(_ error: Error) -> (title: String, body: String) {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return (
+                    String(localized: "Pump Error"),
+                    String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
+                        + "\n\n\(inner.localizedDescription)"
+                )
+            case let .invalidPumpState(message): return (String(localized: "Pump State Error"), message)
+            case let .glucoseError(message): return (String(localized: "Glucose Error"), message)
+            case let .apsError(message): return (String(localized: "Algorithm Error"), message)
+            case let .manualBasalTemp(message): return (String(localized: "Manual Temp Basal Active"), message)
+            }
+        }
+        return ("Trio", error.localizedDescription)
     }
     }
 
 
     /// Called from the `bolusTrigger` Combine sink (already on
     /// Called from the `bolusTrigger` Combine sink (already on
@@ -1250,6 +1387,10 @@ final class BaseAPSManager: APSManager, Injectable {
     /// Mutations are dispatched onto the queue regardless, so a future
     /// Mutations are dispatched onto the queue regardless, so a future
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
     private func createBolusReporter() {
+        if bolusReporter != nil {
+            return
+        }
+
         processQueue.async {
         processQueue.async {
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter?.addObserver(self)
             self.bolusReporter?.addObserver(self)

+ 32 - 8
Trio/Sources/APS/CGM/PluginSource.swift

@@ -99,23 +99,47 @@ extension PluginSource: CGMManagerDelegate {
         debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)")
         debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)")
     }
     }
 
 
-    func issueAlert(_: LoopKit.Alert) {}
+    /// Forwards CGMManager-issued alerts into the unified `TrioAlertManager`
+    /// pipeline so they get the same in-app banner + UN scheduling + history
+    /// logging treatment as everything else. Used to be a no-op; on
+    /// dev-libre3 / LibreLoop builds that meant CGM-issued alerts (sensor
+    /// failure, signal loss, expiry, etc.) silently dropped.
+    func issueAlert(_ alert: LoopKit.Alert) {
+        glucoseManager?.trioAlertManager?.issueAlert(alert)
+    }
 
 
-    func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
+    func retractAlert(identifier: LoopKit.Alert.Identifier) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier)
+    }
 
 
-    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {}
+    /// LoopKit asks this on reconnect to avoid re-issuing an alert that's
+    /// still live. `TrioAlertManager` deduplicates downstream via its own
+    /// throttler and live-alert table, so answering "no" here is safe — at
+    /// worst we get one duplicate banner, which the throttler suppresses.
+    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion: @escaping (Result<Bool, Error>) -> Void) {
+        completion(.success(false))
+    }
 
 
     func lookupAllUnretracted(
     func lookupAllUnretracted(
         managerIdentifier _: String,
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
 
     func lookupAllUnacknowledgedUnretracted(
     func lookupAllUnacknowledgedUnretracted(
         managerIdentifier _: String,
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
 
-    func recordRetractedAlert(_: LoopKit.Alert, at _: Date) {}
+    /// LoopKit calls this when a manager itself decides an alert is no
+    /// longer relevant. Mirror the action on our pipeline so the in-app
+    /// banner, scheduled UN, and history all clear.
+    func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier)
+    }
 
 
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
         processQueue.async { [weak self] in
         processQueue.async { [weak self] in

+ 20 - 51
Trio/Sources/APS/DeviceDataManager.swift

@@ -21,7 +21,7 @@ protocol DeviceDataManager: GlucoseSource {
     var loopInProgress: Bool { get set }
     var loopInProgress: Bool { get set }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
-    var bolusTrigger: PassthroughSubject<Bool, Never> { get }
+    var bolusTrigger: PassthroughSubject<BolusStatus, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
@@ -62,13 +62,14 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var bluetoothProvider: BluetoothStateManager!
     @Injected() private var bluetoothProvider: BluetoothStateManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
 
     @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil
     @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil
     @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date =
     @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date =
         .distantPast
         .distantPast
 
 
     let recommendsLoop = PassthroughSubject<Void, Never>()
     let recommendsLoop = PassthroughSubject<Void, Never>()
-    let bolusTrigger = PassthroughSubject<Bool, Never>()
+    let bolusTrigger = PassthroughSubject<BolusStatus, Never>()
     let errorSubject = PassthroughSubject<Error, Never>()
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
@@ -82,10 +83,16 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
 
     var pumpManager: PumpManagerUI? {
     var pumpManager: PumpManagerUI? {
         didSet {
         didSet {
+            if let oldValue = oldValue {
+                trioAlertManager?.unregister(managerIdentifier: oldValue.pluginIdentifier)
+            }
             if let pumpManager = pumpManager {
             if let pumpManager = pumpManager {
                 pumpManager.pumpManagerDelegate = self
                 pumpManager.pumpManagerDelegate = self
                 pumpManager.delegateQueue = processQueue
                 pumpManager.delegateQueue = processQueue
 
 
+                trioAlertManager?.register(responder: pumpManager, for: pumpManager.pluginIdentifier)
+                trioAlertManager?.register(soundVendor: pumpManager, for: pumpManager.pluginIdentifier)
+
                 /// Since the pump manager has been successfully instantiated from its saved state,
                 /// Since the pump manager has been successfully instantiated from its saved state,
                 /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
                 /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
                 rawPumpManager = pumpManager.rawValue
                 rawPumpManager = pumpManager.rawValue
@@ -244,7 +251,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         injectServices(resolver)
         injectServices(resolver)
         setupPumpManager()
         setupPumpManager()
         UIDevice.current.isBatteryMonitoringEnabled = true
         UIDevice.current.isBatteryMonitoringEnabled = true
-        broadcaster.register(AlertObserver.self, observer: self)
     }
     }
 
 
     func setupPumpManager() {
     func setupPumpManager() {
@@ -467,10 +473,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
         debug(.deviceManager, "New pump status Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
 
 
-        if case .inProgress = status.bolusState {
-            bolusTrigger.send(true)
-        } else {
-            bolusTrigger.send(false)
+        switch status.bolusState {
+        case .initiating:
+            bolusTrigger.send(.initiating)
+        case let .inProgress(dose):
+            bolusTrigger.send(.inProgress)
+        default:
+            bolusTrigger.send(.noBolus)
         }
         }
 
 
         switch status.basalDeliveryState {
         switch status.basalDeliveryState {
@@ -636,22 +645,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
 
 
 extension BaseDeviceDataManager: DeviceManagerDelegate {
 extension BaseDeviceDataManager: DeviceManagerDelegate {
     func issueAlert(_ alert: Alert) {
     func issueAlert(_ alert: Alert) {
-        alertHistoryStorage.addAlert(
-            AlertEntry(
-                alertIdentifier: alert.identifier.alertIdentifier,
-                primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
-                issuedDate: Date(),
-                managerIdentifier: alert.identifier.managerIdentifier,
-                triggerType: alert.trigger.storedType,
-                triggerInterval: alert.trigger.storedInterval as? Decimal,
-                contentTitle: alert.foregroundContent?.title,
-                contentBody: alert.foregroundContent?.body
-            )
-        )
+        debug(.deviceManager, "issueAlert \(alert.identifier.value)")
+        trioAlertManager.issueAlert(alert)
     }
     }
 
 
     func retractAlert(identifier: Alert.Identifier) {
     func retractAlert(identifier: Alert.Identifier) {
-        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+        debug(.deviceManager, "retractAlert \(identifier.value)")
+        trioAlertManager.retractAlert(identifier: identifier)
     }
     }
 
 
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
@@ -706,37 +706,6 @@ extension BaseDeviceDataManager: CGMManagerDelegate {
     func cgmManager(_: CGMManager, didUpdate _: CGMManagerStatus) {}
     func cgmManager(_: CGMManager, didUpdate _: CGMManagerStatus) {}
 }
 }
 
 
-// MARK: - AlertPresenter
-
-extension BaseDeviceDataManager: AlertObserver {
-    func AlertDidUpdate(_ alerts: [AlertEntry]) {
-        alerts.forEach { alert in
-            if alert.acknowledgedDate == nil {
-                ackAlert(alert: alert)
-            }
-        }
-    }
-
-    private func ackAlert(alert: AlertEntry) {
-        let alertIssueDate = alert.issuedDate
-
-        processQueue.async {
-            self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.alertIdentifier) { error in
-                if let error = error {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, error.localizedDescription)
-                    debug(.deviceManager, "acknowledge not succeeded with error \(error)")
-                } else {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, nil)
-                }
-            }
-
-            self.broadcaster.notify(pumpNotificationObserver.self, on: self.processQueue) {
-                $0.pumpNotification(alert: alert)
-            }
-        }
-    }
-}
-
 // extension BaseDeviceDataManager: AlertPresenter {
 // extension BaseDeviceDataManager: AlertPresenter {
 //    func issueAlert(_: Alert) {}
 //    func issueAlert(_: Alert) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}

+ 5 - 0
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -21,6 +21,10 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var shouldSyncToRemoteService: Bool { get }
     var shouldSyncToRemoteService: Bool { get }
     var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
     var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
     var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
     var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
+    /// Routes CGMManager-issued alerts (sensor failure, signal loss, expiry,
+    /// etc.) into the unified `TrioAlertManager` pipeline. Read by
+    /// `PluginSource.issueAlert` / `retractAlert`.
+    var trioAlertManager: TrioAlertManager! { get }
 }
 }
 
 
 extension FetchGlucoseManager {
 extension FetchGlucoseManager {
@@ -42,6 +46,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var pluginCGMManager: PluginManager!
     @Injected() var pluginCGMManager: PluginManager!
     @Injected() var calibrationService: CalibrationService!
     @Injected() var calibrationService: CalibrationService!
+    @Injected() var trioAlertManager: TrioAlertManager!
 
 
     private var lifetime = Lifetime()
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)

+ 60 - 54
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -396,7 +396,7 @@ final class OpenAPS {
     func determineBasal(
     func determineBasal(
         currentTemp: TempBasal,
         currentTemp: TempBasal,
         shouldSmoothGlucose: Bool,
         shouldSmoothGlucose: Bool,
-        useSwiftOref: Bool,
+        useJavascriptOref: Bool,
         clock: Date = Date(),
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -465,7 +465,7 @@ final class OpenAPS {
             clock: clock,
             clock: clock,
             carbs: carbsAsJSON,
             carbs: carbsAsJSON,
             glucose: glucoseAsJSON,
             glucose: glucoseAsJSON,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
         )
 
 
         // IOB calculation
         // IOB calculation
@@ -474,7 +474,7 @@ final class OpenAPS {
             profile: profile,
             profile: profile,
             clock: clock,
             clock: clock,
             autosens: autosens.isEmpty ? .null : autosens,
             autosens: autosens.isEmpty ? .null : autosens,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
         )
 
 
         // TODO: refactor this to core data
         // TODO: refactor this to core data
@@ -502,7 +502,7 @@ final class OpenAPS {
             preferences: preferences,
             preferences: preferences,
             basalProfile: basalProfile,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,
             trioCustomOrefVariables: trioCustomOrefVariables,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
         )
 
 
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
         debug(.openAPS, "\(simulation ? "[SIMULATION]" : "") OREF DETERMINATION: \(orefDetermination)")
@@ -595,7 +595,7 @@ final class OpenAPS {
         }
         }
     }
     }
 
 
-    func autosense(shouldSmoothGlucose: Bool, useSwiftOref: Bool) async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool, useJavascriptOref: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
         debug(.openAPS, "Start autosens")
 
 
         let context = newContext("autosense")
         let context = newContext("autosense")
@@ -626,7 +626,7 @@ final class OpenAPS {
             profile: profile,
             profile: profile,
             carbs: carbsAsJSON,
             carbs: carbsAsJSON,
             temptargets: tempTargets,
             temptargets: tempTargets,
-            useSwiftOref: useSwiftOref
+            useJavascriptOref: useJavascriptOref
         )
         )
 
 
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
@@ -640,7 +640,7 @@ final class OpenAPS {
         }
         }
     }
     }
 
 
-    func createProfiles(useSwiftOref: Bool) async throws {
+    func createProfiles(useJavascriptOref: Bool) async throws {
         debug(.openAPS, "Start creating pump profile and user profile")
         debug(.openAPS, "Start creating pump profile and user profile")
 
 
         // Load required settings and profiles asynchronously
         // Load required settings and profiles asynchronously
@@ -718,7 +718,7 @@ final class OpenAPS {
                 model: model,
                 model: model,
                 autotune: RawJSON.null,
                 autotune: RawJSON.null,
                 trioSettings: trioSettings,
                 trioSettings: trioSettings,
-                useSwiftOref: useSwiftOref,
+                useJavascriptOref: useJavascriptOref,
                 clock: clock
                 clock: clock
             )
             )
 
 
@@ -733,7 +733,7 @@ final class OpenAPS {
                 model: model,
                 model: model,
                 autotune: RawJSON.null,
                 autotune: RawJSON.null,
                 trioSettings: trioSettings,
                 trioSettings: trioSettings,
-                useSwiftOref: useSwiftOref,
+                useJavascriptOref: useJavascriptOref,
                 clock: clock
                 clock: clock
             )
             )
 
 
@@ -749,20 +749,26 @@ final class OpenAPS {
         }
         }
     }
     }
 
 
-    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON, useSwiftOref: Bool) async throws -> RawJSON {
+    private func iob(
+        pumphistory: JSON,
+        profile: JSON,
+        clock: JSON,
+        autosens: JSON,
+        useJavascriptOref: Bool
+    ) async throws -> RawJSON {
         // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
         // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
         var pumphistory = pumphistory
         var pumphistory = pumphistory
         if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
         if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
             pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
             pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
         }
         }
 
 
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
             let swiftResult = OpenAPSSwift
                 .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
                 .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
             return try swiftResult.returnOrThrow()
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await iobJavascript(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
-            return try jsResult.returnOrThrow()
         }
         }
     }
     }
 
 
@@ -797,9 +803,19 @@ final class OpenAPS {
         clock: JSON,
         clock: JSON,
         carbs: JSON,
         carbs: JSON,
         glucose: JSON,
         glucose: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
     ) async throws -> RawJSON {
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await mealJavascript(
+                pumphistory: pumphistory,
+                profile: profile,
+                basalProfile: basalProfile,
+                clock: clock,
+                carbs: carbs,
+                glucose: glucose
+            )
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
             let swiftResult = OpenAPSSwift
                 .meal(
                 .meal(
                     pumphistory: pumphistory,
                     pumphistory: pumphistory,
@@ -810,16 +826,6 @@ final class OpenAPS {
                     glucose: glucose
                     glucose: glucose
                 )
                 )
             return try swiftResult.returnOrThrow()
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await mealJavascript(
-                pumphistory: pumphistory,
-                profile: profile,
-                basalProfile: basalProfile,
-                clock: clock,
-                carbs: carbs,
-                glucose: glucose
-            )
-            return try jsResult.returnOrThrow()
         }
         }
     }
     }
 
 
@@ -863,9 +869,19 @@ final class OpenAPS {
         profile: JSON,
         profile: JSON,
         carbs: JSON,
         carbs: JSON,
         temptargets: JSON,
         temptargets: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
     ) async throws -> RawJSON {
-        if useSwiftOref {
+        if useJavascriptOref {
+            let jsResult = await autosenseJavascript(
+                glucose: glucose,
+                pumpHistory: pumpHistory,
+                basalprofile: basalprofile,
+                profile: profile,
+                carbs: carbs,
+                temptargets: temptargets
+            )
+            return try jsResult.returnOrThrow()
+        } else {
             let swiftResult = OpenAPSSwift
             let swiftResult = OpenAPSSwift
                 .autosense(
                 .autosense(
                     glucose: glucose,
                     glucose: glucose,
@@ -877,16 +893,6 @@ final class OpenAPS {
                     clock: Date()
                     clock: Date()
                 )
                 )
             return try swiftResult.returnOrThrow()
             return try swiftResult.returnOrThrow()
-        } else {
-            let jsResult = await autosenseJavascript(
-                glucose: glucose,
-                pumpHistory: pumpHistory,
-                basalprofile: basalprofile,
-                profile: profile,
-                carbs: carbs,
-                temptargets: temptargets
-            )
-            return try jsResult.returnOrThrow()
         }
         }
     }
     }
 
 
@@ -936,12 +942,12 @@ final class OpenAPS {
         preferences: JSON,
         preferences: JSON,
         basalProfile: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
         trioCustomOrefVariables: JSON,
-        useSwiftOref: Bool
+        useJavascriptOref: Bool
     ) async throws -> RawJSON {
     ) async throws -> RawJSON {
         let clock = Date()
         let clock = Date()
 
 
-        if useSwiftOref {
-            let swiftResult = OpenAPSSwift.determineBasal(
+        if useJavascriptOref {
+            let jsResult = await determineBasalJavascript(
                 glucose: glucose,
                 glucose: glucose,
                 currentTemp: currentTemp,
                 currentTemp: currentTemp,
                 iob: iob,
                 iob: iob,
@@ -956,9 +962,9 @@ final class OpenAPS {
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 clock: clock
                 clock: clock
             )
             )
-            return try swiftResult.returnOrThrow()
+            return try jsResult.returnOrThrow()
         } else {
         } else {
-            let jsResult = await determineBasalJavascript(
+            let swiftResult = OpenAPSSwift.determineBasal(
                 glucose: glucose,
                 glucose: glucose,
                 currentTemp: currentTemp,
                 currentTemp: currentTemp,
                 iob: iob,
                 iob: iob,
@@ -973,7 +979,7 @@ final class OpenAPS {
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 trioCustomOrefVariables: trioCustomOrefVariables,
                 clock: clock
                 clock: clock
             )
             )
-            return try jsResult.returnOrThrow()
+            return try swiftResult.returnOrThrow()
         }
         }
     }
     }
 
 
@@ -1093,11 +1099,11 @@ final class OpenAPS {
         model: JSON,
         model: JSON,
         autotune: JSON,
         autotune: JSON,
         trioSettings: JSON,
         trioSettings: JSON,
-        useSwiftOref: Bool,
+        useJavascriptOref: Bool,
         clock: Date
         clock: Date
     ) async throws -> RawJSON {
     ) async throws -> RawJSON {
-        if useSwiftOref {
-            let swiftResult = OpenAPSSwift.makeProfile(
+        if useJavascriptOref {
+            let jsResult = await makeProfileJavascript(
                 preferences: preferences,
                 preferences: preferences,
                 pumpSettings: pumpSettings,
                 pumpSettings: pumpSettings,
                 bgTargets: bgTargets,
                 bgTargets: bgTargets,
@@ -1106,12 +1112,12 @@ final class OpenAPS {
                 carbRatio: carbRatio,
                 carbRatio: carbRatio,
                 tempTargets: tempTargets,
                 tempTargets: tempTargets,
                 model: model,
                 model: model,
-                trioSettings: trioSettings,
-                clock: clock
+                autotune: autotune,
+                trioSettings: trioSettings
             )
             )
-            return try swiftResult.returnOrThrow()
+            return try jsResult.returnOrThrow()
         } else {
         } else {
-            let jsResult = await makeProfileJavascript(
+            let swiftResult = OpenAPSSwift.makeProfile(
                 preferences: preferences,
                 preferences: preferences,
                 pumpSettings: pumpSettings,
                 pumpSettings: pumpSettings,
                 bgTargets: bgTargets,
                 bgTargets: bgTargets,
@@ -1120,10 +1126,10 @@ final class OpenAPS {
                 carbRatio: carbRatio,
                 carbRatio: carbRatio,
                 tempTargets: tempTargets,
                 tempTargets: tempTargets,
                 model: model,
                 model: model,
-                autotune: autotune,
-                trioSettings: trioSettings
+                trioSettings: trioSettings,
+                clock: clock
             )
             )
-            return try jsResult.returnOrThrow()
+            return try swiftResult.returnOrThrow()
         }
         }
     }
     }
 
 

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Profile/Carbs.swift

@@ -19,7 +19,7 @@ struct Carbs {
         }
         }
 
 
         // Check for invalid values
         // Check for invalid values
-        if currentRatio < 3 || currentRatio > 150 {
+        if currentRatio < 1 || currentRatio > 150 {
             warning(.openAPS, "Warning: carbRatio of \(currentRatio) out of bounds.")
             warning(.openAPS, "Warning: carbRatio of \(currentRatio) out of bounds.")
             return nil
             return nil
         }
         }

+ 6 - 0
Trio/Sources/APS/PluginManager.swift

@@ -1,4 +1,5 @@
 import CGMBLEKit
 import CGMBLEKit
+import EversenseKit
 import Foundation
 import Foundation
 import G7SensorKit
 import G7SensorKit
 import G7SensorKitUI
 import G7SensorKitUI
@@ -40,6 +41,11 @@ class BasePluginManager: Injectable, PluginManager {
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             localizedTitle: String(localized: "FreeStyle Libre"),
             localizedTitle: String(localized: "FreeStyle Libre"),
             manager: LibreTransmitterManagerV3.self
             manager: LibreTransmitterManagerV3.self
+        ),
+        CgmPluginDescription(
+            pluginIdentifier: EversenseCGMManager.pluginIdentifier,
+            localizedTitle: String(localized: "Eversense"),
+            manager: EversenseCGMManager.self
         )
         )
     ]
     ]
 
 

+ 3 - 3
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -228,6 +228,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
         guard context.hasChanges else { return }
         guard context.hasChanges else { return }
         try context.save()
         try context.save()
+        updateSubject.send()
     }
     }
 
 
     private func storeGlucoseBatch(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
     private func storeGlucoseBatch(_ glucose: [BloodGlucose], context: NSManagedObjectContext) throws {
@@ -246,7 +247,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             }
             }
         )
         )
         try context.execute(batchInsert)
         try context.execute(batchInsert)
-        // Only send update for batch insert since regular save triggers CoreData notifications
         updateSubject.send()
         updateSubject.send()
     }
     }
 
 
@@ -808,11 +808,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
 
                 let glucoseValue = glucose.glucose
                 let glucoseValue = glucose.glucose
 
 
-                if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
+                if Decimal(glucoseValue) <= settingsManager.settings.low {
                     return .low
                     return .low
                 }
                 }
 
 
-                if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
+                if Decimal(glucoseValue) >= settingsManager.settings.high {
                     return .high
                     return .high
                 }
                 }
 
 

+ 3 - 0
Trio/Sources/Application/TrioApp.swift

@@ -90,6 +90,9 @@ extension Notification.Name {
             _ = resolver.resolve(LiveActivityManager.self)!
             _ = resolver.resolve(LiveActivityManager.self)!
         }
         }
         _ = resolver.resolve(IOBService.self)!
         _ = resolver.resolve(IOBService.self)!
+        _ = resolver.resolve(GlucoseAlertCoordinator.self)!
+        _ = resolver.resolve(NotLoopingMonitor.self)!
+        _ = DeviceAlertsStore.shared
     }
     }
 
 
     init() {
     init() {

+ 6 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -17,6 +17,12 @@ final class ServiceAssembly: Assembly {
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
+        container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
+            .inObjectScope(.container)
+        container.register(GlucoseAlertCoordinator.self) { r in GlucoseAlertCoordinator(resolver: r) }
+            .inObjectScope(.container)
+        container.register(NotLoopingMonitor.self) { r in NotLoopingMonitor(resolver: r) }
+            .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }

+ 1 - 0
Trio/Sources/Helpers/CGMOptions.swift

@@ -4,6 +4,7 @@ let cgmOptions: [CGMOption] = [
     CGMOption(name: "Dexcom G7 / ONE+", predicate: { $0.type == .plugin && $0.displayName.contains("G7") }),
     CGMOption(name: "Dexcom G7 / ONE+", predicate: { $0.type == .plugin && $0.displayName.contains("G7") }),
     CGMOption(name: "Dexcom Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "Dexcom Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
+    CGMOption(name: "Eversense", predicate: { $0.type == .plugin && $0.displayName == "Eversense" }),
     CGMOption(
     CGMOption(
         name: "FreeStyle Libre Demo",
         name: "FreeStyle Libre Demo",
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }

+ 1 - 1
Trio/Sources/Helpers/Formatters.swift

@@ -100,7 +100,7 @@ extension Formatter {
         let formatter = NumberFormatter()
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.numberStyle = .decimal
         formatter.locale = .current
         formatter.locale = .current
-        formatter.minimumIntegerDigits = 0
+        formatter.minimumIntegerDigits = 1
         formatter.maximumFractionDigits = 2
         formatter.maximumFractionDigits = 2
         return formatter
         return formatter
     }()
     }()

+ 29 - 0
Trio/Sources/Helpers/TimeInterval+Convenience.swift

@@ -0,0 +1,29 @@
+import Foundation
+
+/// Convenience constructors + getters used across the app for `TimeInterval`
+/// math at minute/hour granularity. Previously lived inside the deleted
+/// `Snooze` module; broken out here because real callers exist outside the
+/// alert pipeline (chart markers, pump-history windows, fetch timers, etc.).
+extension TimeInterval {
+    static func seconds(_ seconds: Double) -> TimeInterval { seconds }
+
+    static func minutes(_ minutes: Double) -> TimeInterval {
+        TimeInterval(minutes: minutes)
+    }
+
+    static func hours(_ hours: Double) -> TimeInterval {
+        TimeInterval(minutes: hours * 60)
+    }
+
+    init(minutes: Double) {
+        self.init(minutes * 60)
+    }
+
+    init(hours: Double) {
+        self.init(minutes: hours * 60)
+    }
+
+    var minutes: Double { self / 60.0 }
+
+    var hours: Double { minutes / 60.0 }
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 8733 - 207
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 1 - 47
Trio/Sources/Logger/Logger.swift

@@ -5,7 +5,6 @@ import UIKit
 var LoggerTestMode = false
 var LoggerTestMode = false
 
 
 private let baseReporter = TrioApp.resolver.resolve(GroupedIssueReporter.self)!
 private let baseReporter = TrioApp.resolver.resolve(GroupedIssueReporter.self)!
-private let router = TrioApp.resolver.resolve(Router.self)!
 
 
 let loggerLock = NSRecursiveLock()
 let loggerLock = NSRecursiveLock()
 
 
@@ -28,37 +27,13 @@ func debug(
 func info(
 func info(
     _ category: Logger.Category,
     _ category: Logger.Category,
     _ message: String,
     _ message: String,
-    type: MessageType = .info,
     file: String = #file,
     file: String = #file,
     function: String = #function,
     function: String = #function,
     line: UInt = #line
     line: UInt = #line
 ) {
 ) {
     DispatchWorkItem(qos: .background, flags: .enforceQoS) {
     DispatchWorkItem(qos: .background, flags: .enforceQoS) {
         loggerLock.perform {
         loggerLock.perform {
-            category.logger.info(message, type: type, file: file, function: function, line: line)
-        }
-    }.perform()
-}
-
-func info(
-    _ category: Logger.Category,
-    _ message: String,
-    notificationText: String,
-    type: MessageType = .info,
-    file: String = #file,
-    function: String = #function,
-    line: UInt = #line
-) {
-    DispatchWorkItem(qos: .background, flags: .enforceQoS) {
-        loggerLock.perform {
-            category.logger.info(
-                message,
-                notificationText: notificationText,
-                type: type,
-                file: file,
-                function: function,
-                line: line
-            )
+            category.logger.info(message, file: file, function: function, line: line)
         }
         }
     }.perform()
     }.perform()
 }
 }
@@ -268,18 +243,6 @@ final class Logger {
 
 
     func info(
     func info(
         _ message: String,
         _ message: String,
-        type: MessageType = .info,
-        file: String = #file,
-        function: String = #function,
-        line: UInt = #line
-    ) {
-        info(message, notificationText: message, type: type, file: file, function: function, line: line)
-    }
-
-    func info(
-        _ message: String,
-        notificationText: String,
-        type: MessageType = .info,
         file: String = #file,
         file: String = #file,
         function: String = #function,
         function: String = #function,
         line: UInt = #line
         line: UInt = #line
@@ -287,8 +250,6 @@ final class Logger {
         let printedMessage = "INFO: \(message)"
         let printedMessage = "INFO: \(message)"
         os_log("%@ - %@ - %d %{public}@", log: log, type: .info, file.file, function, line, printedMessage)
         os_log("%@ - %@ - %d %{public}@", log: log, type: .info, file.file, function, line, printedMessage)
         reporter.log(category.name, printedMessage, file: file, function: function, line: line)
         reporter.log(category.name, printedMessage, file: file, function: function, line: line)
-
-        showAlert(notificationText, type: type)
     }
     }
 
 
     func warning(
     func warning(
@@ -324,13 +285,6 @@ final class Logger {
         )
         )
     }
     }
 
 
-    private func showAlert(_ message: String, type: MessageType = .info) {
-        DispatchQueue.main.async {
-            let messageCont = MessageContent(content: message, type: type)
-            router.alertMessage.send(messageCont)
-        }
-    }
-
     fileprivate func errorWithoutFatalError(
     fileprivate func errorWithoutFatalError(
         _ message: String,
         _ message: String,
         description: String? = nil,
         description: String? = nil,

+ 7 - 0
Trio/Sources/Models/BolusStatus.swift

@@ -0,0 +1,7 @@
+import LoopKit
+
+enum BolusStatus {
+    case noBolus
+    case initiating
+    case inProgress
+}

+ 0 - 2
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -33,8 +33,6 @@ class PickerSettingsProvider: ObservableObject {
 }
 }
 
 
 struct DecimalPickerSettings {
 struct DecimalPickerSettings {
-    var lowGlucose = PickerSetting(value: 70, step: 5, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
-    var highGlucose = PickerSetting(value: 180, step: 5, min: 100, max: 400, type: PickerSetting.PickerSettingType.glucose)
     var carbsRequiredThreshold = PickerSetting(value: 10, step: 1, min: 0, max: 100, type: PickerSetting.PickerSettingType.gram)
     var carbsRequiredThreshold = PickerSetting(value: 10, step: 1, min: 0, max: 100, type: PickerSetting.PickerSettingType.gram)
     var individualAdjustmentFactor = PickerSetting(
     var individualAdjustmentFactor = PickerSetting(
         value: 0.5,
         value: 0.5,

+ 34 - 0
Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift

@@ -0,0 +1,34 @@
+import Foundation
+
+/// Catalog of the bundled critical-alarm sound files (`Trio/Resources/Sounds/`).
+/// Ported from Loop's audio-critical-alerts branch. Single source of truth
+/// for the alarm sound picker.
+enum AlarmSoundCatalog {
+    /// (filename, displayName) tuples in display order.
+    private static let catalog: [(filename: String, displayName: String)] = [
+        ("urgent_low.caf", String(localized: "Urgent Low")),
+        ("critical.caf", String(localized: "Critical")),
+        ("alarm.caf", String(localized: "Alarm")),
+        ("bright_alarm.caf", String(localized: "Bright Alarm")),
+        ("honk.caf", String(localized: "Honk")),
+        ("trill.caf", String(localized: "Trill")),
+        ("chime.caf", String(localized: "Chime")),
+        ("clear_chimes.caf", String(localized: "Clear Chimes")),
+        ("high_chimes.caf", String(localized: "High Chimes")),
+        ("dings.caf", String(localized: "Dings")),
+        ("bloom.caf", String(localized: "Bloom")),
+        ("bloop.caf", String(localized: "Bloop")),
+        ("spring.caf", String(localized: "Spring")),
+        ("minimal.caf", String(localized: "Minimal")),
+        ("simple.caf", String(localized: "Simple")),
+        ("synth.caf", String(localized: "Synth")),
+        ("mood_synth.caf", String(localized: "Mood Synth")),
+        ("crying.caf", String(localized: "Crying"))
+    ]
+
+    static let allFilenames: [String] = catalog.map(\.filename)
+
+    static func displayName(for filename: String) -> String {
+        catalog.first { $0.filename == filename }?.displayName ?? filename
+    }
+}

+ 33 - 0
Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+protocol DayNightDisplayable {
+    var displayName: String { get }
+}
+
+extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
+    var displayName: String {
+        rawValue == "always"
+            ? String(localized: "Day & Night")
+            : rawValue.localizedCapitalized
+    }
+}
+
+enum ActiveOption: String, CaseIterable, Codable, Identifiable, DayNightDisplayable {
+    case always
+    case day
+    case night
+
+    var id: String { rawValue }
+}
+
+struct TimeOfDay: Codable, Equatable, Hashable {
+    var hour: Int
+    var minute: Int
+
+    init(hour: Int, minute: Int) {
+        self.hour = max(0, min(23, hour))
+        self.minute = max(0, min(59, minute))
+    }
+
+    var minutesSinceMidnight: Int { hour * 60 + minute }
+}

+ 79 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -0,0 +1,79 @@
+import Foundation
+import LoopKit
+
+/// Coarse-grained tier the user configures for device alarms. Wraps
+/// `Alert.InterruptionLevel` plus its visible behavior:
+/// - `.critical` overrides Silence & Focus Mode (uses the critical-audio
+///   fallback for builds without the Critical Alerts entitlement)
+/// - `.timeSensitive` pierces normal banner suppression but obeys DND/silent
+/// - `.normal` fires only when the device isn't silenced — informational
+enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
+    case critical
+    case timeSensitive
+    case normal
+
+    var id: String { rawValue }
+
+    init?(level: Alert.InterruptionLevel) {
+        switch level {
+        case .critical: self = .critical
+        case .timeSensitive: self = .timeSensitive
+        case .active: self = .normal
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case .critical: return String(localized: "Critical")
+        case .timeSensitive: return String(localized: "Time-Sensitive")
+        case .normal: return String(localized: "Normal")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .critical:
+            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 by default.")
+        case .normal:
+            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode by default.")
+        }
+    }
+
+    var hintText: String {
+        switch self {
+        case .critical:
+            return String(
+                localized: "For situations that require prompt attention. These break through Silent Mode, Do Not Disturb, and any Focus you have enabled. Examples: a pump fault, an occlusion, or Trio not looping for too long. Heads up: if your build of Trio has Apple's Critical Alerts entitlement, iOS plays its own critical alert sound and the sound you picked for this alert category is ignored."
+            )
+        case .timeSensitive:
+            return String(
+                localized: "For things you should know about soon, but not 'act right now'. These can break through banner suppression on the lock screen, but they still obey Silent Mode and Focus by default. Examples: reservoir running low, pod or patch expiring soon, or glucose data going stale."
+            )
+        case .normal:
+            return String(
+                localized: "For everyday heads-up notifications. These behave like a standard banner — they stay quiet when your phone is silenced or a Focus is on. Examples: an algorithm error, a sensor expiration reminder, or a time-zone change being detected."
+            )
+        }
+    }
+
+    var defaultSoundFilename: String {
+        switch self {
+        case .critical: return "alarm.caf"
+        case .timeSensitive: return "chime.caf"
+        case .normal: return "bloop.caf"
+        }
+    }
+
+    /// Default for the per-tier override toggle when seeded. Tier names are
+    /// labels now — the actual `Alert.InterruptionLevel` is derived from the
+    /// override flag at fire time (true → `.critical`, false → `.timeSensitive`).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .critical: return true
+        case .normal,
+             .timeSensitive: return false
+        }
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+/// One user-configured behavior variant for a device-alarm severity tier.
+/// Multiple configs per severity are allowed — each with its own
+/// `activeOption` (Day & Night / Day only / Night only) — so the user can
+/// e.g. have a Critical config that overrides Silence during the day and
+/// a second Critical config that goes silent at night.
+///
+/// Lookup at fire time picks the variant whose `activeOption` matches the
+/// current day/night window, falling back to the `.always` variant.
+struct DeviceAlertSeverityConfig: Codable, Equatable, Identifiable {
+    var id: UUID
+    var severity: DeviceAlertSeverity
+    var isEnabled: Bool
+    var soundFilename: String
+    var playsSound: Bool
+    /// When true, alarms in this tier bypass Focus Mode / silent switch
+    /// (maps to `Alert.InterruptionLevel.critical` and engages the in-process
+    /// `CriticalAlertAudioPlayer` fallback if `playsSound` is true).
+    /// When false, the alarm uses `.timeSensitive`.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+
+    init(
+        id: UUID = UUID(),
+        severity: DeviceAlertSeverity,
+        activeOption: ActiveOption = .always
+    ) {
+        self.id = id
+        self.severity = severity
+        isEnabled = true
+        soundFilename = severity.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = severity.defaultOverridesSilenceAndDND
+        self.activeOption = activeOption
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case severity
+        case isEnabled
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        severity = try container.decode(DeviceAlertSeverity.self, forKey: .severity)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? severity.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? severity.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+    }
+}

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

@@ -0,0 +1,77 @@
+import Foundation
+
+/// A single configurable glucose alarm. Multiple entries of the same type are
+/// allowed — e.g. a Low alarm `active: .day` at 80 mg/dL plus a second Low
+/// `active: .night` at 70 mg/dL.
+struct GlucoseAlert: Identifiable, Codable, Equatable {
+    var id: UUID
+    var type: GlucoseAlertType
+    var name: String
+    var isEnabled: Bool
+    var thresholdMgDL: Decimal
+    var soundFilename: String
+    /// When false, the alarm fires the banner / notification but no sound.
+    /// iOS still drives haptics from the interruption level.
+    var playsSound: Bool
+    /// When true, this alarm bypasses Focus Mode / silent switch
+    /// modes. Maps to `Alert.InterruptionLevel.critical` and triggers the
+    /// in-process `CriticalAlertAudioPlayer` fallback for builds without the
+    /// Critical Alerts entitlement.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+    /// Per-alarm snooze. Distinct from the global mute on `AlertMuter`.
+    var snoozedUntil: Date?
+
+    init(type: GlucoseAlertType) {
+        id = UUID()
+        self.type = type
+        name = type.displayName
+        isEnabled = true
+        thresholdMgDL = type.defaultThresholdMgDL
+        soundFilename = type.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = type.defaultOverridesSilenceAndDND
+        activeOption = .always
+        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 {
+        case id
+        case type
+        case name
+        case isEnabled
+        case thresholdMgDL
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+        case snoozedUntil
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decode(UUID.self, forKey: .id)
+        type = try container.decode(GlucoseAlertType.self, forKey: .type)
+        name = try container.decode(String.self, forKey: .name)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        thresholdMgDL = try container.decode(Decimal.self, forKey: .thresholdMgDL)
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? type.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? type.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+        snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+struct GlucoseAlertConfiguration: Codable, Equatable {
+    var dayStart: TimeOfDay
+    var nightStart: TimeOfDay
+    /// Force Trio alarms on even when the CGM advertises its own.
+    var forceTrioAlertsWhenCGMProvidesOwn: Bool
+
+    init(
+        dayStart: TimeOfDay = TimeOfDay(hour: 6, minute: 0),
+        nightStart: TimeOfDay = TimeOfDay(hour: 22, minute: 0),
+        forceTrioAlertsWhenCGMProvidesOwn: Bool = false
+    ) {
+        self.dayStart = dayStart
+        self.nightStart = nightStart
+        self.forceTrioAlertsWhenCGMProvidesOwn = forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case dayStart
+        case nightStart
+        case forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        dayStart = try container.decode(TimeOfDay.self, forKey: .dayStart)
+        nightStart = try container.decode(TimeOfDay.self, forKey: .nightStart)
+        forceTrioAlertsWhenCGMProvidesOwn = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .forceTrioAlertsWhenCGMProvidesOwn
+        ) ?? false
+    }
+
+    /// Resolve whether `date` falls into the user's "night" window. Mirrors
+    /// LoopFollow's logic: handles both same-day (06→22) and wrap-around
+    /// (22→06) ranges. When `nightStart >= dayStart`, night is "later than
+    /// nightStart OR earlier than dayStart"; otherwise night is the slice
+    /// between nightStart and dayStart.
+    func isNight(at date: Date, calendar: Calendar = .current) -> Bool {
+        let startOfDay = calendar.startOfDay(for: date)
+        guard
+            let dayStartDate = calendar.date(
+                bySettingHour: dayStart.hour,
+                minute: dayStart.minute,
+                second: 0,
+                of: startOfDay
+            ),
+            let nightStartDate = calendar.date(
+                bySettingHour: nightStart.hour,
+                minute: nightStart.minute,
+                second: 0,
+                of: startOfDay
+            )
+        else { return false }
+
+        if nightStartDate >= dayStartDate {
+            return date >= nightStartDate || date < dayStartDate
+        } else {
+            return date >= nightStartDate && date < dayStartDate
+        }
+    }
+}

+ 103 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift

@@ -0,0 +1,103 @@
+import Foundation
+import LoopKit
+
+/// Glucose-driven alarm types Trio supports today. Order is priority order
+/// (`urgentLow` highest), matching how `AlarmType.priority` works in
+/// LoopFollow — when multiple alarms would fire on the same reading, the
+/// higher-priority one wins and lower types of the same group are skipped.
+enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
+    case urgentLow
+    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
+    /// between per-type and global mute routing.
+    init?(slug: String) {
+        let parts = slug.split(separator: ".")
+        guard parts.count >= 2, parts[0] == "glucose" else { return nil }
+        guard let parsed = GlucoseAlertType(rawValue: String(parts[1])) else { return nil }
+        self = parsed
+    }
+
+    var displayName: String {
+        switch self {
+        case .urgentLow: return String(localized: "Urgent Low Glucose")
+        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")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .urgentLow: return String(localized: "Fires when glucose drops to or below an urgent low threshold.")
+        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 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
+        }
+    }
+
+    /// Default bundled sound filename. See `Trio/Resources/Sounds/`.
+    var defaultSoundFilename: String {
+        switch self {
+        case .urgentLow: return "urgent_low.caf"
+        case .low: return "trill.caf"
+        case .forecastedLow: return "bloom.caf"
+        case .high: return "chime.caf"
+        case .carbsRequired: return "bloop.caf"
+        }
+    }
+
+    /// Default for `GlucoseAlert.overridesSilenceAndDND` when adding a new
+    /// alarm. Urgent-low defaults to override-on to match Loop's stance;
+    /// others default off (time-sensitive, doesn't pierce DND / Focus).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .urgentLow: return true
+        case .carbsRequired,
+             .forecastedLow,
+             .high,
+             .low: return false
+        }
+    }
+}

+ 0 - 27
Trio/Sources/Models/GlucoseNotificationsOption.swift

@@ -1,27 +0,0 @@
-//
-//  GlucoseNotificationOption.swift
-//  FreeAPS
-//
-//  Created by Kimberlie Skandis on 1/18/25.
-//
-import Foundation
-import SwiftUI
-
-public enum GlucoseNotificationsOption: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
-    case disabled
-    case alwaysEveryCGM
-    case onlyAlarmLimits
-
-    public var id: String { rawValue }
-
-    var displayName: String {
-        switch self {
-        case .disabled:
-            return String(localized: "Disabled", comment: "Option to disable glucose notifications")
-        case .alwaysEveryCGM:
-            return String(localized: "Always", comment: "Option to always notify on every CGM reading")
-        case .onlyAlarmLimits:
-            return String(localized: "Only Alarm Limits", comment: "Option to notify only when glucose reaches alarm limits")
-        }
-    }
-}

+ 10 - 0
Trio/Sources/Models/GlucoseSourceKey.swift

@@ -0,0 +1,10 @@
+import Foundation
+
+/// Keys used by `GlucoseSource.sourceInfo()` implementations to advertise
+/// metadata about the active CGM (description, transmitter battery,
+/// Nightscout ping). Consumed by status views + diagnostics.
+enum GlucoseSourceKey: String {
+    case transmitterBattery
+    case nightscoutPing
+    case description
+}

+ 11 - 9
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -6,7 +6,7 @@ enum NotificationCategoryIdentifier: String {
 }
 }
 
 
 enum NotificationResponseAction: String, CaseIterable {
 enum NotificationResponseAction: String, CaseIterable {
-    case snooze20 = "Trio.snooze20"
+    case snooze15 = "Trio.snooze15"
     case snooze1hr = "Trio.snooze1hr"
     case snooze1hr = "Trio.snooze1hr"
     case snooze3hr = "Trio.snooze3hr"
     case snooze3hr = "Trio.snooze3hr"
     case snooze6hr = "Trio.snooze6hr"
     case snooze6hr = "Trio.snooze6hr"
@@ -17,8 +17,8 @@ enum NotificationResponseAction: String, CaseIterable {
 
 
     var minutes: Int {
     var minutes: Int {
         switch self {
         switch self {
-        case .snooze20:
-            return 20
+        case .snooze15:
+            return 15
         case .snooze1hr:
         case .snooze1hr:
             return 60
             return 60
         case .snooze3hr:
         case .snooze3hr:
@@ -30,14 +30,14 @@ enum NotificationResponseAction: String, CaseIterable {
 
 
     var localizedTitle: String {
     var localizedTitle: String {
         switch self {
         switch self {
-        case .snooze20:
-            return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze15:
+            return String(localized: "Snooze 15 min", comment: "Snooze glucose alerts for 15 minutes")
         case .snooze1hr:
         case .snooze1hr:
-            return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour")
+            return String(localized: "Snooze 1 hr", comment: "Snooze glucose alerts for 1 hour")
         case .snooze3hr:
         case .snooze3hr:
-            return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours")
+            return String(localized: "Snooze 3 hrs", comment: "Snooze glucose alerts for 3 hours")
         case .snooze6hr:
         case .snooze6hr:
-            return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours")
+            return String(localized: "Snooze 6 hrs", comment: "Snooze glucose alerts for 6 hours")
         }
         }
     }
     }
 }
 }
@@ -54,11 +54,13 @@ enum NotificationCategoryFactory {
             )
             )
         }
         }
 
 
+        // `.customDismissAction` — otherwise iOS swallows swipes silently and
+        // the in-app banner outlives the notification.
         return UNNotificationCategory(
         return UNNotificationCategory(
             identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
             identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
             actions: snoozeActions,
             actions: snoozeActions,
             intentIdentifiers: [],
             intentIdentifiers: [],
-            options: []
+            options: [.customDismissAction]
         )
         )
     }
     }
 }
 }

+ 13 - 49
Trio/Sources/Models/TrioSettings.swift

@@ -30,14 +30,6 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarEmojis: Bool = false
     var displayCalendarEmojis: Bool = false
     var glucoseBadge: Bool = false
     var glucoseBadge: Bool = false
-    var notificationsPump: Bool = true
-    var notificationsCgm: Bool = true
-    var notificationsCarb: Bool = true
-    var notificationsAlgorithm: Bool = true
-    var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-    var addSourceInfoToGlucoseNotifications: Bool = false
-    var lowGlucose: Decimal = 72
-    var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
     var carbsRequiredThreshold: Decimal = 10
     var showCarbsRequiredBadge: Bool = true
     var showCarbsRequiredBadge: Bool = true
     var useFPUconversion: Bool = false
     var useFPUconversion: Bool = false
@@ -70,13 +62,15 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var sweetMealFactor: Decimal = 1
     var sweetMealFactor: Decimal = 1
     var displayPresets: Bool = true
     var displayPresets: Bool = true
     var confirmBolus: Bool = false
     var confirmBolus: Bool = false
+    var enableQuickBolus: Bool = false
     var useLiveActivity: Bool = false
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
+    var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
     var requireAdjustmentsConfirmation: Bool = false
-    var useSwiftOref: Bool = false
+    var useJavascriptOref: Bool = false
 
 
     /// Selected Garmin watchface (Trio or SwissAlpine)
     /// Selected Garmin watchface (Trio or SwissAlpine)
     var garminWatchface: GarminWatchface = .trio
     var garminWatchface: GarminWatchface = .trio
@@ -214,44 +208,6 @@ extension TrioSettings: Decodable {
             settings.delay = delay
             settings.delay = delay
         }
         }
 
 
-        if let notificationsPump = try? container.decode(Bool.self, forKey: .notificationsPump) {
-            settings.notificationsPump = notificationsPump
-        }
-
-        if let notificationsCgm = try? container.decode(Bool.self, forKey: .notificationsCgm) {
-            settings.notificationsCgm = notificationsCgm
-        }
-
-        if let notificationsCarb = try? container.decode(Bool.self, forKey: .notificationsCarb) {
-            settings.notificationsCarb = notificationsCarb
-        }
-
-        if let notificationsAlgorithm = try? container.decode(Bool.self, forKey: .notificationsAlgorithm) {
-            settings.notificationsAlgorithm = notificationsAlgorithm
-        }
-
-        if let glucoseNotificationsOption = try? container.decode(
-            GlucoseNotificationsOption.self,
-            forKey: .glucoseNotificationsOption
-        ) {
-            settings.glucoseNotificationsOption = glucoseNotificationsOption
-        }
-
-        if let addSourceInfoToGlucoseNotifications = try? container.decode(
-            Bool.self,
-            forKey: .addSourceInfoToGlucoseNotifications
-        ) {
-            settings.addSourceInfoToGlucoseNotifications = addSourceInfoToGlucoseNotifications
-        }
-
-        if let lowGlucose = try? container.decode(Decimal.self, forKey: .lowGlucose) {
-            settings.lowGlucose = lowGlucose
-        }
-
-        if let highGlucose = try? container.decode(Decimal.self, forKey: .highGlucose) {
-            settings.highGlucose = highGlucose
-        }
-
         if let carbsRequiredThreshold = try? container.decode(Decimal.self, forKey: .carbsRequiredThreshold) {
         if let carbsRequiredThreshold = try? container.decode(Decimal.self, forKey: .carbsRequiredThreshold) {
             settings.carbsRequiredThreshold = carbsRequiredThreshold
             settings.carbsRequiredThreshold = carbsRequiredThreshold
         }
         }
@@ -340,6 +296,10 @@ extension TrioSettings: Decodable {
             settings.confirmBolus = confirmBolus
             settings.confirmBolus = confirmBolus
         }
         }
 
 
+        if let enableQuickBolus = try? container.decode(Bool.self, forKey: .enableQuickBolus) {
+            settings.enableQuickBolus = enableQuickBolus
+        }
+
         if let useLiveActivity = try? container.decode(Bool.self, forKey: .useLiveActivity) {
         if let useLiveActivity = try? container.decode(Bool.self, forKey: .useLiveActivity) {
             settings.useLiveActivity = useLiveActivity
             settings.useLiveActivity = useLiveActivity
         }
         }
@@ -352,6 +312,10 @@ extension TrioSettings: Decodable {
             settings.smartStackView = smartStackView
             settings.smartStackView = smartStackView
         }
         }
 
 
+        if let displayGlucoseForecasts = try? container.decode(Bool.self, forKey: .displayGlucoseForecasts) {
+            settings.displayGlucoseForecasts = displayGlucoseForecasts
+        }
+
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
             settings.bolusShortcut = bolusShortcut
             settings.bolusShortcut = bolusShortcut
         }
         }
@@ -364,8 +328,8 @@ extension TrioSettings: Decodable {
             settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
             settings.requireAdjustmentsConfirmation = requireAdjustmentsConfirmation
         }
         }
 
 
-        if let useSwiftOref = try? container.decode(Bool.self, forKey: .useSwiftOref) {
-            settings.useSwiftOref = useSwiftOref
+        if let useJavascriptOref = try? container.decode(Bool.self, forKey: .useJavascriptOref) {
+            settings.useJavascriptOref = useJavascriptOref
         }
         }
 
 
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {
         if let garminWatchface = try? container.decode(GarminWatchface.self, forKey: .garminWatchface) {

+ 7 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum AlarmWindows {
+    enum Config {}
+}
+
+protocol AlarmWindowsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension AlarmWindows {
+    final class Provider: BaseProvider, AlarmWindowsProvider {}
+}

+ 18 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift

@@ -0,0 +1,18 @@
+import Combine
+import SwiftUI
+
+extension AlarmWindows {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+        }
+    }
+}
+
+extension AlarmWindows.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 135 - 0
Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift

@@ -0,0 +1,135 @@
+import SwiftUI
+import Swinject
+
+extension AlarmWindows {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var shouldDisplayHint: Bool = false
+        @State private var hintDetent = PresentationDetent.large
+        @State private var selectedVerboseHint: AnyView?
+        @State private var hintLabel: String?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        private var dayStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.dayStart) },
+                set: { store.configuration.dayStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        private var nightStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.nightStart) },
+                set: { store.configuration.nightStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        var body: some View {
+            List {
+                Section(header: Text("Window Boundaries")) {
+                    VStack {
+                        DatePicker(
+                            selection: dayStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "sun.max.fill").foregroundStyle(.orange)
+                                Text("Day Starts")
+                            }
+                        }
+                        .padding(.top)
+
+                        DatePicker(
+                            selection: nightStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "moon.stars.fill").foregroundStyle(.indigo)
+                                Text("Night Starts")
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text("Decides when each alarm's Day or Night setting applies.")
+                                .lineLimit(nil)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Day and Night Window")
+                                    let dayDefault = Self.formattedTimeOfDay(hour: 6, minute: 0)
+                                    let nightDefault = Self.formattedTimeOfDay(hour: 22, minute: 0)
+                                    selectedVerboseHint = AnyView(
+                                        VStack(alignment: .leading, spacing: 10) {
+                                            Text(
+                                                String(
+                                                    format: String(localized: "Default: Day starts %1$@, Night starts %2$@."),
+                                                    dayDefault,
+                                                    nightDefault
+                                                )
+                                            ).bold()
+                                            Text(
+                                                "These two times define the Day and Night windows. Each alarm's Active setting picks one — Day & Night, Day only, or Night only — and only fires when that window is current."
+                                            )
+                                            Text(
+                                                String(
+                                                    format: String(
+                                                        localized: "The Night window runs from 'Night Starts' back around to 'Day Starts' — so by default, Night covers %1$@ through %2$@ the next morning."
+                                                    ),
+                                                    nightDefault,
+                                                    dayDefault
+                                                )
+                                            )
+                                            Text(
+                                                "These windows are shared between Glucose Alarms and Device Alarms."
+                                            )
+                                        }
+                                    )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: { Image(systemName: "questionmark.circle") }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Day & Night Windows")
+            .navigationBarTitleDisplayMode(.inline)
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help")
+                )
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private static func dateFromTimeOfDay(_ time: TimeOfDay) -> Date {
+            Calendar.current.date(bySettingHour: time.hour, minute: time.minute, second: 0, of: Date()) ?? Date()
+        }
+
+        private static func timeOfDay(from date: Date) -> TimeOfDay {
+            let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
+            return TimeOfDay(hour: comps.hour ?? 0, minute: comps.minute ?? 0)
+        }
+
+        /// Locale-aware HH:mm / h:mm a. Used so 12-hour locales see "10:00 PM"
+        /// instead of "22:00" in hint copy.
+        private static func formattedTimeOfDay(hour: Int, minute: Int) -> String {
+            let date = Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date()
+            return date.formatted(.dateTime.hour().minute())
+        }
+    }
+}

+ 3 - 3
Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift

@@ -22,7 +22,7 @@ extension AlgorithmAdvancedSettings {
         @Published var remainingCarbsFraction: Decimal = 1.0
         @Published var remainingCarbsFraction: Decimal = 1.0
         @Published var remainingCarbsCap: Decimal = 90
         @Published var remainingCarbsCap: Decimal = 90
         @Published var noisyCGMTargetMultiplier: Decimal = 1.3
         @Published var noisyCGMTargetMultiplier: Decimal = 1.3
-        @Published var useSwiftOref: Bool = false
+        @Published var useJavascriptOref: Bool = false
         // preference
         // preference
         @Published var insulinActionCurve: Decimal = 10
         @Published var insulinActionCurve: Decimal = 10
         @Published var smbDeliveryRatio: Decimal = 0.5
         @Published var smbDeliveryRatio: Decimal = 0.5
@@ -48,8 +48,8 @@ extension AlgorithmAdvancedSettings {
             subscribePreferencesSetting(\.remainingCarbsCap, on: $remainingCarbsCap) { remainingCarbsCap = $0 }
             subscribePreferencesSetting(\.remainingCarbsCap, on: $remainingCarbsCap) { remainingCarbsCap = $0 }
             subscribePreferencesSetting(\.noisyCGMTargetMultiplier, on: $noisyCGMTargetMultiplier) {
             subscribePreferencesSetting(\.noisyCGMTargetMultiplier, on: $noisyCGMTargetMultiplier) {
                 noisyCGMTargetMultiplier = $0 }
                 noisyCGMTargetMultiplier = $0 }
-            subscribeSetting(\.useSwiftOref, on: $useSwiftOref) {
-                useSwiftOref = $0 }
+            subscribeSetting(\.useJavascriptOref, on: $useJavascriptOref) {
+                useJavascriptOref = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
             subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
             subscribePreferencesSetting(\.smbInterval, on: $smbInterval) { smbInterval = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }
             subscribePreferencesSetting(\.smbDeliveryRatio, on: $smbDeliveryRatio) { smbDeliveryRatio = $0 }

+ 7 - 8
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -392,31 +392,30 @@ extension AlgorithmAdvancedSettings {
                 )
                 )
                 SettingInputSection(
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
                     decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.useSwiftOref,
+                    booleanValue: $state.useJavascriptOref,
                     shouldDisplayHint: $shouldDisplayHint,
                     shouldDisplayHint: $shouldDisplayHint,
                     selectedVerboseHint: Binding(
                     selectedVerboseHint: Binding(
                         get: { selectedVerboseHint },
                         get: { selectedVerboseHint },
                         set: {
                         set: {
                             selectedVerboseHint = $0.map { AnyView($0) }
                             selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref")
+                            hintLabel = NSLocalizedString("Use JavaScript Oref", comment: "Use JavaScript Oref")
                         }
                         }
                     ),
                     ),
                     units: state.units,
                     units: state.units,
                     type: .boolean,
                     type: .boolean,
-                    label: NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref"),
+                    label: NSLocalizedString("Use JavaScript Oref", comment: "Use JavaScript Oref"),
                     miniHint: String(
                     miniHint: String(
-                        localized: "EXPERIMENTAL FEATURE! Enables new, fully Swift-based algorithm version.",
-                        comment: "Use Swift Oref mini hint"
+                        localized: "Switches back to the legacy JavaScript-based algorithm version.",
+                        comment: "Use JavaScript Oref mini hint"
                     ),
                     ),
                     verboseHint:
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text("Default: OFF").bold()
-                        Text("This feature is EXPERIMENTAL and not yet cleared for general use.").bold().foregroundStyle(.orange)
                         Text(
                         Text(
-                            "We're building a faster and more maintainable Swift version of the algorithm (Oref) that runs in Trio. It's faster, more accurate and improves Trio for everyone."
+                            "Trio now uses a fully Swift-based version of the algorithm (Oref) by default. It's faster, more accurate and improves Trio for everyone."
                         )
                         )
                         Text(
                         Text(
-                            "When enabled, Trio will no longer use the old JavaScript-based algorithm that runs virtualized on your phone. Instead, it will use a fully Swift-based algorithm."
+                            "When enabled, Trio will instead use the legacy JavaScript-based algorithm that runs virtualized on your phone. Only enable this if you encounter issues with the Swift-based algorithm."
                         )
                         )
 
 
                         Text(
                         Text(

+ 31 - 10
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -1,3 +1,4 @@
+import LoopKit
 import LoopKitUI
 import LoopKitUI
 import SwiftUI
 import SwiftUI
 import Swinject
 import Swinject
@@ -26,6 +27,26 @@ extension CGMSettings {
         @State private var simulatedScenarioRaw: String = UserDefaults.standard
         @State private var simulatedScenarioRaw: String = UserDefaults.standard
             .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
             .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
 
 
+        /// Routes "open URL failed" warnings through `TrioAlertManager` so
+        /// they share the same in-app banner UI as the rest of the alert
+        /// pipeline (no more SwiftMessages roundtrip).
+        private func warnOpenFailed(identifier: String, title: String, body: String) {
+            let content = Alert.Content(
+                title: title,
+                body: body,
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(managerIdentifier: "trio.cgmSettings", alertIdentifier: identifier),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            resolver.resolve(TrioAlertManager.self)?.issueAlert(alert)
+        }
+
         // Initialize state variables with defaults if needed
         // Initialize state variables with defaults if needed
         private func initializeSimulatorSettings() {
         private func initializeSimulatorSettings() {
             if centerValue == 0 {
             if centerValue == 0 {
@@ -69,11 +90,11 @@ extension CGMSettings {
                                 Button {
                                 Button {
                                     UIApplication.shared.open(appURL, options: [:]) { success in
                                     UIApplication.shared.open(appURL, options: [:]) { success in
                                         if !success {
                                         if !success {
-                                            self.router.alertMessage
-                                                .send(MessageContent(
-                                                    content: "Unable to open the app",
-                                                    type: .warning
-                                                ))
+                                            warnOpenFailed(
+                                                identifier: "cgm.app.open.failed",
+                                                title: String(localized: "Open failed"),
+                                                body: String(localized: "Unable to open the app")
+                                            )
                                         }
                                         }
                                     }
                                     }
                                 }
                                 }
@@ -156,11 +177,11 @@ extension CGMSettings {
                         Button {
                         Button {
                             UIApplication.shared.open(url, options: [:]) { success in
                             UIApplication.shared.open(url, options: [:]) { success in
                                 if !success {
                                 if !success {
-                                    self.router.alertMessage
-                                        .send(MessageContent(
-                                            content: "No URL available",
-                                            type: .warning
-                                        ))
+                                    warnOpenFailed(
+                                        identifier: "nightscout.open.failed",
+                                        title: String(localized: "Open failed"),
+                                        body: String(localized: "No URL available")
+                                    )
                                 }
                                 }
                             }
                             }
                         }
                         }

+ 27 - 1
Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift

@@ -1,4 +1,5 @@
 import CoreData
 import CoreData
+import LoopKit
 import Observation
 import Observation
 import SwiftDate
 import SwiftDate
 import SwiftUI
 import SwiftUI
@@ -7,6 +8,7 @@ extension Calibrations {
     @Observable final class StateModel: BaseStateModel<Provider> {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
         @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
         @ObservationIgnored @Injected() var calibrationService: CalibrationService!
         @ObservationIgnored @Injected() var calibrationService: CalibrationService!
+        @ObservationIgnored @Injected() var trioAlertManager: TrioAlertManager!
 
 
         var slope: Double = 1
         var slope: Double = 1
         var intercept: Double = 1
         var intercept: Double = 1
@@ -77,7 +79,8 @@ extension Calibrations {
 
 
                     calibrationService.addCalibration(calibration)
                     calibrationService.addCalibration(calibration)
                 } else {
                 } else {
-                    info(.service, "Glucose is stale for calibration")
+                    debug(.service, "Glucose is stale for calibration")
+                    issueStaleGlucoseAlert()
                     return
                     return
                 }
                 }
             } catch {
             } catch {
@@ -85,6 +88,29 @@ extension Calibrations {
             }
             }
         }
         }
 
 
+        /// Surfaces the "glucose too stale to calibrate against" condition as
+        /// a one-shot info alert through `TrioAlertManager`. Mirrors the old
+        /// `info(.service, …)` banner path that ran via `router.alertMessage`.
+        private func issueStaleGlucoseAlert() {
+            let content = Alert.Content(
+                title: String(localized: "Calibration unavailable"),
+                body: String(localized: "Glucose is stale for calibration"),
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(
+                    managerIdentifier: "trio.calibration",
+                    alertIdentifier: "glucose.stale"
+                ),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            trioAlertManager?.issueAlert(alert)
+        }
+
         func removeLast() {
         func removeLast() {
             calibrationService.removeLast()
             calibrationService.removeLast()
             setupCalibrations()
             setupCalibrations()

+ 7 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum DeviceAlarms {
+    enum Config {}
+}
+
+protocol DeviceAlarmsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension DeviceAlarms {
+    final class Provider: BaseProvider, DeviceAlarmsProvider {}
+}

+ 8 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift

@@ -0,0 +1,8 @@
+import Combine
+import SwiftUI
+
+extension DeviceAlarms {
+    final class StateModel: BaseStateModel<Provider> {
+        override func subscribe() {}
+    }
+}

+ 65 - 0
Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift

@@ -0,0 +1,65 @@
+import SwiftUI
+
+struct AddDeviceAlarmSheet: View {
+    let onPick: (DeviceAlertSeverity) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(DeviceAlertSeverity.allCases) { severity in
+                        Button {
+                            onPick(severity)
+                            dismiss()
+                        } label: {
+                            HStack(spacing: 12) {
+                                Image(systemName: icon(for: severity))
+                                    .foregroundStyle(tint(for: severity))
+                                    .frame(width: 28)
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(severity.displayName)
+                                        .foregroundColor(.primary)
+                                    Text(severity.blurb)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Variant")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private func icon(for severity: DeviceAlertSeverity) -> String {
+        switch severity {
+        case .critical: return "exclamationmark.triangle.fill"
+        case .timeSensitive: return "bell.badge.fill"
+        case .normal: return "bell.fill"
+        }
+    }
+
+    private func tint(for severity: DeviceAlertSeverity) -> Color {
+        switch severity {
+        case .critical: return .red
+        case .timeSensitive: return .orange
+        case .normal: return .accentColor
+        }
+    }
+}

+ 129 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -0,0 +1,129 @@
+import LoopKit
+import SwiftUI
+
+struct DeviceAlarmEditorView: View {
+    @ObservedObject var store: DeviceAlertsStore
+    let configID: UUID
+    let isNew: Bool
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: DeviceAlertSeverityConfig
+
+    init(
+        store: DeviceAlertsStore,
+        initial: DeviceAlertSeverityConfig,
+        isNew: Bool,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        configID = initial.id
+        self.isNew = isNew
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section(
+                    header: Text("Behavior"),
+                    footer: Text(working.severity.blurb)
+                ) {
+                    HStack {
+                        Text(working.severity.displayName).font(.headline)
+                        Spacer()
+                        Text(activeLabel)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                    // 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
+                    )
+                }.listRowBackground(Color.chart)
+
+                AlarmActiveSection(activeOption: $working.activeOption)
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                Section(header: Text("Applies To (Cannot be changed)")) {
+                    ForEach(conceptsForTier(working.severity), id: \.self) { concept in
+                        Text(concept.displayTitle)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                }.listRowBackground(Color.chart)
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Variant")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.severity.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    private var activeLabel: String {
+        switch working.activeOption {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+
+    /// Distinct alarm concepts whose catalog entries fall into this tier.
+    /// Sorted by display title so the list is stable across plugin changes.
+    private func conceptsForTier(_ tier: DeviceAlertSeverity) -> [LoopKit.Alert.CatalogConcept] {
+        var seen: Set<LoopKit.Alert.CatalogConcept> = []
+        var ordered: [LoopKit.Alert.CatalogConcept] = []
+        for entry in AlertCatalogRegistry.entries
+            where DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+        {
+            if seen.insert(entry.concept).inserted {
+                ordered.append(entry.concept)
+            }
+        }
+        return ordered.sorted { $0.displayTitle < $1.displayTitle }
+    }
+}

+ 226 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -0,0 +1,226 @@
+import SwiftUI
+import Swinject
+
+private enum DeviceAlarmSheet: Identifiable {
+    case picker
+    case editor(DeviceAlertSeverityConfig, isNew: Bool)
+    case help(DeviceAlertSeverity)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(config, _): return config.id.uuidString
+        case let .help(severity): return "helpSheet_" + severity.id
+        }
+    }
+}
+
+extension DeviceAlarms {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = DeviceAlertsStore.shared
+
+        @State private var sheet: DeviceAlarmSheet?
+        @State private var pendingNewSeverity: DeviceAlertSeverity?
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        var body: some View {
+            List {
+                ForEach(DeviceAlertSeverity.allCases) { severity in
+                    Section {
+                        VStack(alignment: .leading, spacing: 5) {
+                            HStack {
+                                Image(systemName: severityIcon(for: severity))
+                                    .foregroundStyle(severityTint(for: severity))
+                                Text(severity.displayName)
+                                Spacer()
+                            }.font(.headline)
+
+                            HStack(alignment: .center) {
+                                Text(severity.blurb)
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                                    .lineLimit(nil)
+                                    .fixedSize(horizontal: false, vertical: true)
+                                Spacer()
+                                Button(action: {
+                                    sheet = .help(severity)
+                                }) {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                                .buttonStyle(BorderlessButtonStyle())
+                            }.padding(.vertical, 5)
+                        }
+
+                        ForEach(store.configs(in: severity)) { config in
+                            row(for: config)
+                                .opacity(config.isEnabled ? 1 : 0.5)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                Section {
+                    Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Device Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddDeviceAlarmSheet { severity in
+                        pendingNewSeverity = severity
+                        sheet = nil
+                    }
+                case let .editor(config, isNew):
+                    DeviceAlarmEditorView(
+                        store: store,
+                        initial: config,
+                        isNew: isNew,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                case let .help(severity):
+                    SettingInputHintView(
+                        hintDetent: $hintDetent,
+                        shouldDisplayHint: Binding(
+                            get: { sheet != nil },
+                            set: { if !$0 { sheet = nil } }
+                        ),
+                        hintLabel: String(
+                            localized: "\(severity.displayName) Device Alerts",
+                            comment: "Device Alerts help sheet label; text reads: '<severity level> Device Alerts'."
+                        ),
+                        hintText: selectedVerboseHint ?? AnyView(
+                            VStack(alignment: .leading, spacing: 10) {
+                                Text(severity.hintText)
+                            }
+                        ),
+                        sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                    )
+                }
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private func handleSheetDismiss() {
+            guard let severity = pendingNewSeverity else { return }
+            pendingNewSeverity = nil
+            DispatchQueue.main.async {
+                let new = DeviceAlertSeverityConfig(
+                    severity: severity,
+                    activeOption: nextAvailableOption(for: severity)
+                )
+                sheet = .editor(new, isNew: true)
+            }
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for config: DeviceAlertSeverityConfig) -> some View {
+            Button {
+                sheet = .editor(config, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: config.activeOption)
+                        .font(.title3)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(windowLabel(for: config.activeOption))
+                            .foregroundColor(.primary)
+                        soundSummary(for: config)
+                    }
+                    Spacer()
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(config) {
+                    Button(role: .destructive) {
+                        store.remove(config)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        private func soundSummary(for config: DeviceAlertSeverityConfig) -> some View {
+            // Show the sound and override facts independently. Previously
+            // the override badge was hidden when sound was off, but "sound
+            // off + override on" is a valid combo (silent + haptic that
+            // breaks through Focus / Sleep) and the user needs to see it.
+            HStack(spacing: 6) {
+                HStack(spacing: 4) {
+                    Image(systemName: config.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(config.playsSound ? "Sound on" : "Sound off")
+                }
+                if config.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+
+        // MARK: - Helpers
+
+        private func windowLabel(for option: ActiveOption) -> String {
+            switch option {
+            case .always: return String(localized: "Day & Night")
+            case .day: return String(localized: "Day only")
+            case .night: return String(localized: "Night only")
+            }
+        }
+
+        private func severityIcon(for severity: DeviceAlertSeverity) -> String {
+            switch severity {
+            case .critical: return "exclamationmark.triangle.fill"
+            case .timeSensitive: return "bell.badge.fill"
+            case .normal: return "bell.fill"
+            }
+        }
+
+        private func severityTint(for severity: DeviceAlertSeverity) -> Color {
+            switch severity {
+            case .critical: return .red
+            case .timeSensitive: return .orange
+            case .normal: return .accentColor
+            }
+        }
+
+        /// Suggest an `ActiveOption` not yet used in this severity tier so the
+        /// editor opens on a meaningful new variant instead of duplicating
+        /// the existing `.always` row.
+        private func nextAvailableOption(for severity: DeviceAlertSeverity) -> ActiveOption {
+            let used = Set(store.configs(in: severity).map(\.activeOption))
+            for candidate in [ActiveOption.day, .night, .always] where !used.contains(candidate) {
+                return candidate
+            }
+            return .day
+        }
+    }
+}

+ 7 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum GlucoseAlerts {
+    enum Config {}
+}
+
+protocol GlucoseAlertsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension GlucoseAlerts {
+    final class Provider: BaseProvider, GlucoseAlertsProvider {}
+}

+ 36 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift

@@ -0,0 +1,36 @@
+import Combine
+import SwiftUI
+
+extension GlucoseAlerts {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var fetchGlucoseManager: FetchGlucoseManager!
+
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var glucoseBadge = false
+        @Published var cgmAppInfo: CGMManagerAlertOwnership.OwningApp?
+
+        var cgmProvidesOwnAlerts: Bool { cgmAppInfo != nil }
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            refreshCGMOwnership()
+            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
+        }
+
+        /// Re-read whether the active CGM owns its own glucose alerts. The
+        /// active manager can change while the user is in CGM settings, so
+        /// the alarms view calls this on appear.
+        func refreshCGMOwnership() {
+            cgmAppInfo = CGMManagerAlertOwnership.owningApp(
+                manager: fetchGlucoseManager?.cgmManager,
+                sourceType: fetchGlucoseManager?.cgmGlucoseSourceType ?? .none
+            )
+        }
+    }
+}
+
+extension GlucoseAlerts.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 87 - 0
Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift

@@ -0,0 +1,87 @@
+import SwiftUI
+
+struct AddGlucoseAlertSheet: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let onPick: (GlucoseAlertType) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                if !availableTypes.isEmpty {
+                    Section(header: Text("Available")) {
+                        ForEach(availableTypes) { type in
+                            availableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !unavailableTypes.isEmpty {
+                    Section(
+                        header: Text("Unavailable"),
+                        footer: Text(
+                            "Already set for Day & Night. Change the alarm time window to add a second alarm for the same type."
+                        )
+                    ) {
+                        ForEach(unavailableTypes) { type in
+                            unavailableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Alarm")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private var availableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { !store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private var unavailableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private func availableRow(for type: GlucoseAlertType) -> some View {
+        Button {
+            onPick(type)
+            dismiss()
+        } label: {
+            HStack(spacing: 12) {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(type.displayName)
+                        .foregroundColor(.primary)
+                    Text(type.blurb)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+    }
+
+    private func unavailableRow(for type: GlucoseAlertType) -> some View {
+        HStack(spacing: 12) {
+            VStack(alignment: .leading, spacing: 2) {
+                Text(type.displayName)
+                    .foregroundColor(.primary)
+                Text(type.blurb)
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+            Spacer()
+        }
+        .opacity(0.5)
+    }
+}

+ 23 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift

@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct AlarmActiveSection: View {
+    @Binding var activeOption: ActiveOption
+    /// Subset of `ActiveOption` cases the picker is allowed to offer.
+    /// Defaults to all cases when omitted.
+    var allowed: [ActiveOption] = ActiveOption.allCases
+
+    var body: some View {
+        Section(
+            header: Text("Active During"),
+            footer: Text(
+                "Day and Night windows are configured globally on the Alarms screen."
+            )
+        ) {
+            AlarmEnumMenuPicker(
+                title: String(localized: "Active"),
+                selection: $activeOption,
+                allowed: allowed
+            )
+        }.listRowBackground(Color.chart)
+    }
+}

+ 168 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift

@@ -0,0 +1,168 @@
+import AVFoundation
+import SwiftUI
+
+struct AlarmAudioSection: View {
+    @Binding var playsSound: Bool
+    @Binding var soundFilename: String
+
+    @State private var showTonePicker = false
+
+    var body: some View {
+        Section(header: Text("Alert Sound")) {
+            Toggle("Play Sound", isOn: $playsSound)
+
+            if playsSound {
+                Button {
+                    showTonePicker = true
+                } label: {
+                    HStack {
+                        Text("Tone")
+                        Spacer()
+                        Text(AlarmSoundCatalog.displayName(for: soundFilename))
+                            .foregroundColor(.secondary)
+                        Image(systemName: "chevron.right")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
+                }
+                .buttonStyle(.plain)
+                .sheet(isPresented: $showTonePicker) {
+                    TonePickerSheet(selected: $soundFilename)
+                }
+            }
+        }
+        .listRowBackground(Color.chart)
+    }
+}
+
+private struct TonePickerSheet: View {
+    @Binding var selected: String
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @StateObject private var previewer = SoundPreviewPlayer()
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(AlarmSoundCatalog.allFilenames, id: \.self) { filename in
+                        TonePickerRow(
+                            filename: filename,
+                            selected: $selected,
+                            previewer: previewer
+                        )
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Choose Tone")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        previewer.stop()
+                        dismiss()
+                    }
+                }
+            }
+            .onDisappear { previewer.stop() }
+        }
+    }
+}
+
+private struct TonePickerRow: View {
+    let filename: String
+    @Binding var selected: String
+    @ObservedObject var previewer: SoundPreviewPlayer
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Button {
+                selected = filename
+            } label: {
+                Text(AlarmSoundCatalog.displayName(for: filename))
+                    .foregroundColor(.primary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+
+            Button {
+                previewer.toggle(filename: filename)
+            } label: {
+                Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
+                    .font(.title3)
+                    .foregroundColor(.accentColor)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Image(systemName: "checkmark")
+                .foregroundColor(.accentColor)
+                .opacity(filename == selected ? 1 : 0)
+                .frame(width: 20)
+        }
+    }
+
+    private var isPlaying: Bool { previewer.playing == filename }
+}
+
+/// Plays bundled alarm `.caf` files for in-picker auditioning. Mixable
+/// `.playback` session so it ducks other audio without taking it over.
+/// Not the critical-alert player — this one obeys the silent switch.
+@MainActor private final class SoundPreviewPlayer: ObservableObject {
+    @Published private(set) var playing: String?
+    private var player: AVAudioPlayer?
+
+    func toggle(filename: String) {
+        if playing == filename {
+            stop()
+        } else {
+            play(filename: filename)
+        }
+    }
+
+    func play(filename: String) {
+        stop()
+        let resource = (filename as NSString).deletingPathExtension
+        let ext = (filename as NSString).pathExtension.isEmpty ? "caf" : (filename as NSString).pathExtension
+        guard let url = Bundle.main.url(forResource: resource, withExtension: ext, subdirectory: "Sounds") else {
+            return
+        }
+        do {
+            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
+            try AVAudioSession.sharedInstance().setActive(true, options: [])
+            let p = try AVAudioPlayer(contentsOf: url)
+            p.delegate = previewDelegate
+            p.prepareToPlay()
+            guard p.play() else { return }
+            player = p
+            playing = filename
+        } catch {
+            debug(.service, "Audio preview failed for \(filename): \(error)")
+        }
+    }
+
+    func stop() {
+        player?.stop()
+        player = nil
+        playing = nil
+        try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+    }
+
+    private lazy var previewDelegate: PreviewDelegate = {
+        let delegate = PreviewDelegate()
+        delegate.onFinish = { [weak self] in self?.stop() }
+        return delegate
+    }()
+}
+
+private final class PreviewDelegate: NSObject, AVAudioPlayerDelegate {
+    var onFinish: (() -> Void)?
+
+    func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully _: Bool) {
+        Task { @MainActor in self.onFinish?() }
+    }
+}

+ 54 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift

@@ -0,0 +1,54 @@
+import SwiftUI
+
+struct AlarmBGSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Decimal>
+    let step: Decimal
+    let units: GlucoseUnits
+    @Binding var valueMgDL: 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(valueMgDL.formatted(for: units))
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(units.rawValue)
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: $valueMgDL) {
+                        ForEach(pickerValues, id: \.self) { value in
+                            Text(value.formatted(for: units)).tag(value)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    private var pickerValues: [Decimal] {
+        let setting = PickerSetting(
+            value: valueMgDL,
+            step: step,
+            min: range.lowerBound,
+            max: range.upperBound,
+            type: .glucose
+        )
+        return PickerSettingsProvider.shared.generatePickerValues(from: setting, units: units)
+    }
+}

+ 39 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift

@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct AlarmEnumMenuPicker<E: CaseIterable & Hashable & DayNightDisplayable>: View {
+    let title: String
+    @Binding var selection: E
+    let allowed: [E]
+
+    init(title: String, selection: Binding<E>, allowed: [E]) {
+        self.title = title
+        _selection = selection
+        self.allowed = allowed
+    }
+
+    init(title: String, selection: Binding<E>) where E.AllCases: RandomAccessCollection {
+        self.title = title
+        _selection = selection
+        allowed = Array(E.allCases)
+    }
+
+    var body: some View {
+        HStack {
+            Text(title)
+            Spacer()
+            Picker("", selection: $selection) {
+                ForEach(allowed, id: \.self) { option in
+                    Text(option.displayName).tag(option)
+                }
+            }
+            .pickerStyle(.menu)
+            .onAppear(perform: validate)
+            .onChange(of: allowed) { _, _ in validate() }
+        }
+    }
+
+    private func validate() {
+        guard !allowed.contains(selection), let first = allowed.first else { return }
+        selection = first
+    }
+}

+ 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)
+    }
+}

+ 44 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift

@@ -0,0 +1,44 @@
+import SwiftUI
+
+/// Small SF Symbol that visually conveys an alarm's `ActiveOption` window:
+/// sun for day-only, moon-and-stars for night-only, overlapped pair for both.
+/// The frame is locked to the widest variant (the overlapped pair) so alarm
+/// titles don't shift horizontally when alarms with different windows are
+/// listed together.
+struct AlarmWindowIcon: View {
+    let option: ActiveOption
+
+    var body: some View {
+        ZStack {
+            // Hidden width anchor — always the widest variant. `.hidden()`
+            // keeps it in the layout but invisible, so the frame size scales
+            // with the caller's font (Dynamic Type-friendly) without
+            // hardcoding a width.
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                Image(systemName: "moon.stars.fill")
+            }
+            .hidden()
+
+            content
+        }
+    }
+
+    @ViewBuilder private var content: some View {
+        switch option {
+        case .day:
+            Image(systemName: "sun.max.fill")
+                .foregroundStyle(.orange)
+        case .night:
+            Image(systemName: "moon.stars.fill")
+                .foregroundStyle(.indigo)
+        case .always:
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                    .foregroundStyle(.orange)
+                Image(systemName: "moon.stars.fill")
+                    .foregroundStyle(.indigo)
+            }
+        }
+    }
+}

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

@@ -0,0 +1,191 @@
+import SwiftUI
+
+struct GlucoseAlertEditorView: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let alertID: UUID
+    let isNew: Bool
+
+    /// Windows the user can still pick without overlapping another alarm of
+    /// the same type. The being-edited alarm is excluded from "taken" so its
+    /// current option stays valid.
+    private var allowedActiveOptions: [ActiveOption] {
+        let available = store.availableActiveOptions(
+            forType: working.type,
+            excludingAlertID: isNew ? nil : alertID
+        )
+        return ActiveOption.allCases.filter { available.contains($0) }
+    }
+
+    let units: GlucoseUnits
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: GlucoseAlert
+
+    init(
+        store: GlucoseAlertsStore,
+        initial: GlucoseAlert,
+        isNew: Bool,
+        units: GlucoseUnits,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        alertID = initial.id
+        self.isNew = isNew
+        self.units = units
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                generalSection
+
+                switch working.type {
+                case .urgentLow: urgentLowBody
+                case .low: lowBody
+                case .forecastedLow: forecastedLowBody
+                case .high: highBody
+                case .carbsRequired: carbsRequiredBody
+                }
+
+                AlarmActiveSection(
+                    activeOption: $working.activeOption,
+                    allowed: allowedActiveOptions
+                )
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Alarm")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.type.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    // MARK: - Sections
+
+    private var generalSection: some View {
+        Section(
+            header: Text("General"),
+            footer: Text(working.type.blurb)
+        ) {
+            TextField(String(localized: "Name"), text: $working.name)
+            // 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
+            )
+        }.listRowBackground(Color.chart)
+    }
+
+    private var urgentLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Urgent Low Threshold"),
+            footer: String(
+                localized: "Recommended to always override silence and Focus mode."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 80,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var lowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var forecastedLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when the forecast at +20 minutes (blended across all available prediction curves) is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var highBody: some View {
+        AlarmBGSection(
+            header: String(localized: "High Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or above this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 100 ... 400,
+            step: 1,
+            units: units,
+            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
+        )
+    }
+}

+ 345 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift

@@ -0,0 +1,345 @@
+import SwiftUI
+import Swinject
+
+private enum AlertSheet: Identifiable {
+    case picker
+    case editor(GlucoseAlert, isNew: Bool)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(alert, _): return alert.id.uuidString
+        }
+    }
+}
+
+extension GlucoseAlerts {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var sheet: AlertSheet?
+        @State private var pendingNewType: GlucoseAlertType?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+        @State private var displayPickerLowGlucose: Bool = false
+        @State private var displayPickerHighGlucose: Bool = false
+
+        var body: some View {
+            List {
+                if !enabledAlerts.isEmpty {
+                    Section(header: Text("Enabled")) {
+                        ForEach(enabledAlerts) { alarm in
+                            row(for: alarm)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !disabledAlerts.isEmpty {
+                    Section(header: Text("Disabled")) {
+                        ForEach(disabledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !cgmHandledAlerts.isEmpty {
+                    Section(
+                        header: Text("Handled by CGM App"),
+                        footer: cgmHandledFooter
+                    ) {
+                        ForEach(cgmHandledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                // FIXME: make this into a nice setting with mini and verbose hint
+                Section {
+                    Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+
+                Section(footer: Text(useCGMAlertsFooter)) {
+                    Toggle(isOn: Binding(
+                        // When the active CGM has no companion app to defer
+                        // to, force the visible state OFF regardless of the
+                        // stored preference — there's nothing for the toggle
+                        // to control, so showing it ON would mislead.
+                        get: {
+                            guard state.cgmProvidesOwnAlerts else { return false }
+                            return !store.configuration.forceTrioAlertsWhenCGMProvidesOwn
+                        },
+                        set: { store.configuration.forceTrioAlertsWhenCGMProvidesOwn = !$0 }
+                    )) {
+                        Text("Use CGM App Alerts")
+                    }
+                    .disabled(!state.cgmProvidesOwnAlerts)
+                }.listRowBackground(Color.chart)
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.glucoseBadge,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Show Glucose App Badge")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: String(localized: "Show Glucose App Badge"),
+                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
+                    verboseHint: VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: OFF").bold()
+                        Text(
+                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
+                        )
+                    },
+                    headerText: String(localized: "Glucose App Badge")
+                )
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Glucose Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddGlucoseAlertSheet(store: store) { type in
+                        pendingNewType = type
+                        sheet = nil
+                    }
+                case let .editor(alarm, isNew):
+                    GlucoseAlertEditorView(
+                        store: store,
+                        initial: alarm,
+                        isNew: isNew,
+                        units: state.units,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                }
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
+            .onAppear {
+                configureView()
+                state.refreshCGMOwnership()
+            }
+        }
+
+        private func handleSheetDismiss() {
+            guard let type = pendingNewType else { return }
+            pendingNewType = nil
+            DispatchQueue.main.async {
+                var seed = GlucoseAlert(type: type)
+                // Default new alarm to the first available window so it
+                // doesn't overlap with whatever is already configured.
+                let available = store.availableActiveOptions(forNewAlarmOfType: type)
+                if let first = ActiveOption.allCases.first(where: available.contains) {
+                    seed.activeOption = first
+                }
+                sheet = .editor(seed, isNew: true)
+            }
+        }
+
+        // MARK: - Sorted lists
+
+        /// Mirror of `GlucoseAlertCoordinator.shouldRespect(alarm:)`'s
+        /// CGM-ownership branch: when "Use CGM App Alerts" is ON and the
+        /// active CGM provides its own glucose alerts, the coordinator
+        /// silences reading-driven types. The view surfaces this by moving
+        /// those alarms into a dedicated section.
+        private var isCGMSuppressionActive: Bool {
+            !store.configuration.forceTrioAlertsWhenCGMProvidesOwn && state.cgmProvidesOwnAlerts
+        }
+
+        private var cgmHandledAlerts: [GlucoseAlert] {
+            guard isCGMSuppressionActive else { return [] }
+            return store.alerts
+                .filter { $0.isEnabled && $0.type.isReadingDriven }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        /// Footer for the "Use CGM App Alerts" toggle. Names the eligible
+        /// CGMs when one of them is active and the user can decide; when
+        /// the active CGM has no companion app the toggle is disabled and
+        /// the footer says Trio is handling alarms.
+        private var useCGMAlertsFooter: String {
+            if state.cgmProvidesOwnAlerts {
+                return String(
+                    localized:
+                    "Your CGM app handles alerts (Dexcom G6 / One, G7 / One+, or xDrip4iOS). Turn off to let Trio alert you."
+                )
+            }
+            return String(
+                localized:
+                "Your CGM has no companion app, so Trio handles alarms."
+            )
+        }
+
+        /// Footer for the "Handled by CGM App" section. Names the specific
+        /// companion app, and renders its name as a deep link when a URL
+        /// scheme is known for that app (see CGMManagerAlertOwnership).
+        @ViewBuilder private var cgmHandledFooter: some View {
+            if let info = state.cgmAppInfo {
+                Text(handledFooterMarkdown(for: info))
+            } else {
+                Text(
+                    "These alarms are silenced because the CGM app handles them. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                )
+            }
+        }
+
+        private func handledFooterMarkdown(for info: CGMManagerAlertOwnership.OwningApp) -> AttributedString {
+            let body = String(
+                format: String(
+                    localized:
+                    "These alarms are silenced because the %@ app handles CGM alerts. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                ),
+                "{{NAME}}"
+            )
+            var result = AttributedString(body)
+            if let range = result.range(of: "{{NAME}}") {
+                var name = AttributedString(info.name)
+                if let url = info.deepLink {
+                    name.link = url
+                }
+                result.replaceSubrange(range, with: name)
+            }
+            return result
+        }
+
+        private var enabledAlerts: [GlucoseAlert] {
+            let handled = Set(cgmHandledAlerts.map(\.id))
+            return store.alerts
+                .filter(\.isEnabled)
+                .filter { !handled.contains($0.id) }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        private var disabledAlerts: [GlucoseAlert] {
+            store.alerts
+                .filter { !$0.isEnabled }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for alarm: GlucoseAlert) -> some View {
+            Button {
+                sheet = .editor(alarm, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: alarm.activeOption)
+                        .font(.title3)
+
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(alarm.name)
+                            .foregroundColor(.primary)
+                        Text(summary(for: alarm))
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                        soundSummary(for: alarm)
+                    }
+
+                    Spacer()
+
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(alarm) {
+                    Button(role: .destructive) {
+                        store.remove(alarm)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        private func summary(for alarm: GlucoseAlert) -> String {
+            let comparator: String = {
+                switch alarm.type {
+                case .high: return String(localized: "above")
+                case .carbsRequired: return String(localized: "at least")
+                default: return String(localized: "below")
+                }
+            }()
+            let threshold: String = {
+                // `thresholdMgDL` stores grams for carbsRequired — no
+                // mg/dL ↔ mmol/L conversion and a fixed "g" unit label.
+                if alarm.type == .carbsRequired {
+                    return "\(alarm.thresholdMgDL) \(String(localized: "g", comment: "gram of carbs"))"
+                }
+                return "\(alarm.thresholdMgDL.formatted(for: state.units)) \(state.units.rawValue)"
+            }()
+            let window = AlarmEnumDescription.description(for: alarm.activeOption)
+            return "\(comparator.localizedCapitalized) \(threshold) • \(window)"
+        }
+
+        private func soundSummary(for alarm: GlucoseAlert) -> some View {
+            // Show the sound and override facts independently. Previously
+            // the override badge was hidden when sound was off, but "sound
+            // off + override on" is a valid combo (silent + haptic that
+            // breaks through Focus / Sleep) and the user needs to see it.
+            HStack(spacing: 6) {
+                HStack(spacing: 4) {
+                    Image(systemName: alarm.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(alarm.playsSound ? "Sound on" : "Sound off")
+                }
+                if alarm.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+    }
+}
+
+private enum AlarmEnumDescription {
+    static func description(for option: ActiveOption) -> String {
+        switch option {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+}

+ 0 - 5
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift

@@ -1,5 +0,0 @@
-enum GlucoseNotificationSettings {
-    enum Config {}
-}
-
-protocol GlucoseNotificationSettingsProvider {}

+ 0 - 3
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift

@@ -1,3 +0,0 @@
-extension GlucoseNotificationSettings {
-    final class Provider: BaseProvider, GlucoseNotificationSettingsProvider {}
-}

+ 0 - 53
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -1,53 +0,0 @@
-import SwiftUI
-
-extension GlucoseNotificationSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Published var glucoseBadge = false
-        @Published var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-        @Published var addSourceInfoToGlucoseNotifications = false
-        @Published var lowGlucose: Decimal = 0
-        @Published var highGlucose: Decimal = 0
-
-        @Published var notificationsPump = true
-        @Published var notificationsCgm = true
-        @Published var notificationsCarb = true
-        @Published var notificationsAlgorithm = true
-
-        var units: GlucoseUnits = .mgdL
-
-        override func subscribe() {
-            let units = settingsManager.settings.units
-            self.units = units
-
-            subscribeSetting(\.notificationsPump, on: $notificationsPump) { notificationsPump = $0 }
-            subscribeSetting(\.notificationsCgm, on: $notificationsCgm) { notificationsCgm = $0 }
-            subscribeSetting(\.notificationsCarb, on: $notificationsCarb) { notificationsCarb = $0 }
-            subscribeSetting(\.notificationsAlgorithm, on: $notificationsAlgorithm) { notificationsAlgorithm = $0 }
-
-            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
-            subscribeSetting(\.glucoseNotificationsOption, on: $glucoseNotificationsOption) { glucoseNotificationsOption = $0 }
-            subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {
-                addSourceInfoToGlucoseNotifications = $0 }
-
-            subscribeSetting(\.lowGlucose, on: $lowGlucose, initial: {
-                lowGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-
-            subscribeSetting(\.highGlucose, on: $highGlucose, initial: {
-                highGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-        }
-    }
-}
-
-extension GlucoseNotificationSettings.StateModel: SettingsObserver {
-    func settingsDidChange(_: TrioSettings) {
-        units = settingsManager.settings.units
-    }
-}

+ 0 - 389
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -1,389 +0,0 @@
-import ActivityKit
-import Combine
-import SwiftUI
-import Swinject
-
-extension GlucoseNotificationSettings {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        @State private var shouldDisplayHint: Bool = false
-        @State var hintDetent = PresentationDetent.large
-        @State var selectedVerboseHint: AnyView?
-        @State var hintLabel: String?
-        @State private var decimalPlaceholder: Decimal = 0.0
-        @State private var booleanPlaceholder: Bool = false
-        @State private var displayPickerLowGlucose: Bool = false
-        @State private var displayPickerHighGlucose: Bool = false
-
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
-        private var carbsFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            return formatter
-        }
-
-        @Environment(\.colorScheme) var colorScheme
-        @Environment(AppState.self) var appState
-
-        var body: some View {
-            List {
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsPump,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Pump")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Pump"),
-                    miniHint: String(localized: "Always Notify Pump Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Pump Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Pump Warning is 'Pod Expiration Reminder'")
-                    },
-                    headerText: String(localized: "Trio Information Notifications")
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCgm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify CGM")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify CGM"),
-                    miniHint: String(localized: "Always Notify CGM Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most CGM Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a CGM Warning is 'Unable to open the app'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCarb,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Carb")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Carb"),
-                    miniHint: String(localized: "Always Notify Carb Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Carb Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Carb Warning is 'Carbs required: 30 g'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsAlgorithm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Algorithm")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Algorithm"),
-                    miniHint: String(localized: "Always Notify Algorithm Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Algorithm Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text(
-                            "An example of an Algorithm Warning is 'Error: Invalid glucose: Not enough glucose data'"
-                        )
-                    }
-                )
-
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.glucoseBadge,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Show Glucose App Badge")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Show Glucose App Badge"),
-                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
-                    verboseHint: VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
-                        Text(
-                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
-                        )
-                    },
-                    headerText: String(localized: "Various Glucose Notifications")
-                )
-
-                Section {
-                    VStack {
-                        Picker(
-                            selection: $state.glucoseNotificationsOption,
-                            label: Text("Glucose Notifications")
-                        ) {
-                            ForEach(GlucoseNotificationsOption.allCases) { selection in
-                                Text(selection.displayName).tag(selection)
-                            }
-                        }.padding(.top)
-
-                        HStack(alignment: .center) {
-                            Text(
-                                "Choose glucose notifications option. See hint for more details."
-                            )
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                            Spacer()
-                            Button(
-                                action: {
-                                    hintLabel = String(localized: "Glucose Notifications")
-                                    selectedVerboseHint =
-                                        AnyView(
-                                            VStack(alignment: .leading, spacing: 10) {
-                                                Text(
-                                                    "Set the Glucose Notifications Option. Descriptions for each option found below."
-                                                )
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Disabled:").bold()
-                                                    Text("No Glucose Notifications will be triggered.")
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Always:").bold()
-                                                    Text(
-                                                        "A notification will be triggered every time your glucose is updated in Trio."
-                                                    )
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Only Alarm Limits:").bold()
-                                                    Text(
-                                                        "A notification will be triggered only when glucose levels are below the LOW limit or above the HIGH limit, as specified in Glucose Alarm Limits below."
-                                                    )
-                                                }
-                                            }
-                                        )
-                                    shouldDisplayHint.toggle()
-                                },
-                                label: {
-                                    HStack {
-                                        Image(systemName: "questionmark.circle")
-                                    }
-                                }
-                            ).buttonStyle(BorderlessButtonStyle())
-                        }.padding(.top)
-                    }.padding(.bottom)
-                }.listRowBackground(Color.chart)
-
-                if state.glucoseNotificationsOption != GlucoseNotificationsOption.disabled {
-                    self.lowAndHighGlucoseAlertSection
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.addSourceInfoToGlucoseNotifications,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Add Glucose Source to Alarm")
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: String(localized: "Add Glucose Source to Alarm"),
-                        miniHint: String(localized: "Source of the glucose reading will be added to the notification."),
-                        verboseHint: VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: OFF").bold()
-                            Text("The source of the glucose reading will be added to the notification.")
-                        }
-                    )
-                }
-            }
-            .listSectionSpacing(sectionSpacing)
-            .sheet(isPresented: $shouldDisplayHint) {
-                SettingInputHintView(
-                    hintDetent: $hintDetent,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    hintLabel: hintLabel ?? "",
-                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
-                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
-                )
-            }
-            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-            .onAppear(perform: configureView)
-            .navigationBarTitle("Trio Notifications")
-            .navigationBarTitleDisplayMode(.automatic)
-            .settingsHighlightScroll()
-        }
-
-        var lowAndHighGlucoseAlertSection: some View {
-            Section {
-                VStack {
-                    VStack {
-                        HStack {
-                            Text("Low Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.lowGlucose.description : state.lowGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerLowGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerLowGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerLowGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.lowGlucose
-
-                        Picker(selection: $state.lowGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    VStack {
-                        HStack {
-                            Text("High Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.highGlucose.description : state.highGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerHighGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerHighGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerHighGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.highGlucose
-                        Picker(selection: $state.highGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    HStack(alignment: .center) {
-                        Text(
-                            "Sets the lower and upper limit for glucose alarms."
-                        )
-                        .lineLimit(nil)
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
-                        Spacer()
-                        Button(
-                            action: {
-                                hintLabel = String(localized: "Low and High Glucose Alarm Limits")
-                                selectedVerboseHint =
-                                    AnyView(VStack(alignment: .leading, spacing: 10) {
-                                        let low: Decimal = 70
-                                        let high: Decimal = 180
-                                        let labelLow = (state.units == .mgdL ? low.description : low.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        let labelHigh = (state.units == .mgdL ? high.description : high.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        Text("Low Default: " + labelLow).bold()
-                                        Text("High Default: " + labelHigh).bold()
-                                        VStack(alignment: .leading, spacing: 10) {
-                                            Text(
-                                                "These two settings determine the range outside of which you will be notified via push notifications."
-                                            )
-                                            Text(
-                                                "If your CGM readings are below the Low value or above the High value, you will receive a glucose alarm."
-                                            )
-                                        }
-                                    })
-                                shouldDisplayHint.toggle()
-                            },
-                            label: {
-                                HStack {
-                                    Image(systemName: "questionmark.circle")
-                                }
-                            }
-                        ).buttonStyle(BorderlessButtonStyle())
-                    }.padding(.top)
-                }.padding(.bottom)
-            }.listRowBackground(Color.chart)
-        }
-    }
-}

+ 3 - 0
Trio/Sources/Modules/Home/HomeProvider.swift

@@ -13,6 +13,9 @@ extension Home {
         }
         }
 
 
         func heartbeatNow() {
         func heartbeatNow() {
+            // User-initiated force-loop: bypass dwell suppression so transient
+            // errors surface immediately rather than waiting for count/dwell.
+            apsManager.markNextLoopUserInitiated()
             apsManager.heartbeat(date: Date())
             apsManager.heartbeat(date: Date())
         }
         }
 
 

+ 110 - 1
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -25,6 +25,7 @@ extension Home {
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
         @ObservationIgnored @Injected() var iobService: IOBService!
         @ObservationIgnored @Injected() var iobService: IOBService!
+        @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
 
 
         var cgmStateModel: CGMSettings.StateModel {
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
             CGMSettings.StateModel.shared
@@ -99,6 +100,7 @@ extension Home {
         var tempBasals: [PumpEventStored] = []
         var tempBasals: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []
         var overrides: [OverrideStored] = []
         var overrideRunStored: [OverrideRunStored] = []
         var overrideRunStored: [OverrideRunStored] = []
@@ -120,6 +122,8 @@ extension Home {
         var shouldRunDeleteOnSettingsChange = true
         var shouldRunDeleteOnSettingsChange = true
 
 
         var showCarbsRequiredBadge: Bool = true
         var showCarbsRequiredBadge: Bool = true
+        var enableQuickBolus: Bool = false
+        var quickBolusHistory: [Decimal] = []
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
         var minForecast: [Int] = []
         var minForecast: [Int] = []
         var maxForecast: [Int] = []
         var maxForecast: [Int] = []
@@ -414,6 +418,15 @@ extension Home {
                     self.currentIOB = self.iobService.currentIOB ?? 0
                     self.currentIOB = self.iobService.currentIOB ?? 0
                 }
                 }
                 .store(in: &subscriptions)
                 .store(in: &subscriptions)
+
+            // core-data-fixes drives Core Data updates via NSFetchedResultsController delegates,
+            // so dev's glucose/carbs updatePublisher sinks and the coreDataPublisher-based
+            // registerHandlers() are obsolete here. Only the bolus-status subscription (a genuinely
+            // new feature, consumed by HomeRootView) is carried over, wired in our subscriber style.
+            provider.deviceManager.bolusTrigger
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
+                .store(in: &subscriptions)
         }
         }
 
 
         private func registerObservers() {
         private func registerObservers() {
@@ -520,7 +533,7 @@ extension Home {
                 .map { [weak self] error in
                 .map { [weak self] error in
                     self?.errorDate = error == nil ? nil : Date()
                     self?.errorDate = error == nil ? nil : Date()
                     if let error = error {
                     if let error = error {
-                        info(.default, String(describing: error), notificationText: error.localizedDescription)
+                        debug(.default, "APSManager lastError: \(String(describing: error))")
                     }
                     }
                     return error?.localizedDescription
                     return error?.localizedDescription
                 }
                 }
@@ -578,6 +591,7 @@ extension Home {
             bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
             bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
             thresholdLines = settingsManager.settings.rulerMarks
             thresholdLines = settingsManager.settings.rulerMarks
             showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
             showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
+            enableQuickBolus = settingsManager.settings.enableQuickBolus
             forecastDisplayType = settingsManager.settings.forecastDisplayType
             forecastDisplayType = settingsManager.settings.forecastDisplayType
             isExerciseModeActive = settingsManager.preferences.exerciseMode
             isExerciseModeActive = settingsManager.preferences.exerciseMode
             highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
             highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
@@ -634,6 +648,100 @@ extension Home {
             }
             }
         }
         }
 
 
+        func loadQuickBolusSuggestions() async {
+            guard enableQuickBolus else { return }
+
+            let fetchContext = CoreDataStack.shared.newTaskContext()
+            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: fetchContext,
+                    predicate: predicate,
+                    key: "pumpEvent.timestamp",
+                    ascending: false,
+                    batchSize: 100
+                )
+
+                let suggestions: [Decimal] = await fetchContext.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 -> Bool {
+            guard amount > 0 else { return false }
+            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)
+                    return true
+                }
+                return false
+            } catch {
+                debug(.bolusState, "Quick bolus authentication error: \(error)")
+                return false
+            }
+        }
+
         func addPump(_ type: PumpConfig.PumpType) {
         func addPump(_ type: PumpConfig.PumpType) {
             setupPumpType = type
             setupPumpType = type
             shouldDisplayPumpSetupSheet = true
             shouldDisplayPumpSetupSheet = true
@@ -904,6 +1012,7 @@ extension Home.StateModel:
         thresholdLines = settingsManager.settings.rulerMarks
         thresholdLines = settingsManager.settings.rulerMarks
         bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
         bolusDisplayThreshold = settingsManager.settings.bolusDisplayThreshold
         showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
         showCarbsRequiredBadge = settingsManager.settings.showCarbsRequiredBadge
+        enableQuickBolus = settingsManager.settings.enableQuickBolus
         forecastDisplayType = settingsManager.settings.forecastDisplayType
         forecastDisplayType = settingsManager.settings.forecastDisplayType
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)
         cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none)
         displayPumpStatusHighlightMessage()
         displayPumpStatusHighlightMessage()

+ 31 - 26
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -15,42 +15,47 @@ struct GlucoseChartView: ChartContent {
         drawGlucoseChart()
         drawGlucoseChart()
     }
     }
 
 
+    /// Dynamic point color for a CGM reading. Manual readings render red and skip this.
+    private func pointColor(for item: GlucoseStored) -> Color {
+        // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+        let hardCodedLow = Decimal(55)
+        let hardCodedHigh = Decimal(220)
+        let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+
+        return Trio.getDynamicGlucoseColor(
+            glucoseValue: Decimal(item.glucose),
+            highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+            lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+            targetGlucose: currentGlucoseTarget,
+            glucoseColorScheme: glucoseColorScheme
+        )
+    }
+
     private func drawGlucoseChart() -> some ChartContent {
     private func drawGlucoseChart() -> some ChartContent {
         ForEach(glucoseData) { item in
         ForEach(glucoseData) { item in
             let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
             let glucoseToDisplay = units == .mgdL ? Decimal(item.glucose) : Decimal(item.glucose).asMmolL
 
 
-            // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
-            let hardCodedLow = Decimal(55)
-            let hardCodedHigh = Decimal(220)
-            let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
-
-            let pointMarkColor: Color = Trio.getDynamicGlucoseColor(
-                glucoseValue: Decimal(item.glucose),
-                highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
-                lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
-                targetGlucose: currentGlucoseTarget,
-                glucoseColorScheme: glucoseColorScheme
-            )
-
-            PointMark(
-                x: .value("Time", item.date ?? Date(), unit: .second),
-                y: .value("Value", glucoseToDisplay)
-            )
-            .foregroundStyle(pointMarkColor)
-            .symbolSize(20)
-            .symbol {
-                if item.isManual {
+            if item.isManual {
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .symbolSize(20)
+                .symbol {
                     Image(systemName: "drop.fill")
                     Image(systemName: "drop.fill")
                         .font(.caption2)
                         .font(.caption2)
                         .symbolRenderingMode(.monochrome)
                         .symbolRenderingMode(.monochrome)
                         .bold()
                         .bold()
                         .foregroundStyle(.red)
                         .foregroundStyle(.red)
-                } else {
-                    Image(systemName: "circle.fill")
-                        .font(.system(size: 5))
-                        .bold()
-                        .foregroundStyle(pointMarkColor)
                 }
                 }
+            } else {
+                PointMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", glucoseToDisplay)
+                )
+                .foregroundStyle(pointColor(for: item))
+                .symbolSize(20)
+                .symbol(.circle)
             }
             }
 
 
             if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
             if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {

+ 1 - 1
Trio/Sources/Modules/Home/View/Header/LoopStatusView.swift

@@ -289,7 +289,7 @@ struct LoopStatusView: View {
         }
         }
 
 
         // FIXME: remove this before feat/dev-oref-swift is merged to dev
         // FIXME: remove this before feat/dev-oref-swift is merged to dev
-        if state.settingsManager.settings.useSwiftOref {
+        if !state.settingsManager.settings.useJavascriptOref {
             tags.append("Swift Oref")
             tags.append("Swift Oref")
         }
         }
 
 

+ 59 - 18
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -32,8 +32,11 @@ extension Home {
         @State var isMenuPresented = false
         @State var isMenuPresented = false
         @State var showTreatments = false
         @State var showTreatments = false
         @State var selectedTab: Int = 0
         @State var selectedTab: Int = 0
+        @State var showQuickBolusPicker = false
+        @State var showQuickBolusNoHistory = false
         @State var showPumpSelection: Bool = false
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
         @State var showCGMSelection: Bool = false
+        @State var showSnoozeSheet: Bool = false
         @State var notificationsDisabled = false
         @State var notificationsDisabled = false
         @State var timeButtons: [TimePicker] = [
         @State var timeButtons: [TimePicker] = [
             TimePicker(active: false, hours: 4),
             TimePicker(active: false, hours: 4),
@@ -149,7 +152,7 @@ extension Home {
             .onLongPressGesture {
             .onLongPressGesture {
                 let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
                 let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
                 impactHeavy.impactOccurred()
                 impactHeavy.impactOccurred()
-                state.showModal(for: .snooze)
+                showSnoozeSheet = true
             }
             }
         }
         }
 
 
@@ -789,6 +792,8 @@ extension Home {
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         + String(localized: " U", comment: "Insulin unit")
                         + String(localized: " U", comment: "Insulin unit")
+                let bolusLabel = state
+                    .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
 
                 ZStack {
                 ZStack {
                     /// rectangle as background
                     /// rectangle as background
@@ -814,7 +819,7 @@ extension Home {
                         Spacer()
                         Spacer()
 
 
                         VStack {
                         VStack {
-                            Text("Bolusing")
+                            Text(bolusLabel)
                                 .font(.subheadline)
                                 .font(.subheadline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                             Text(bolusString)
                             Text(bolusString)
@@ -824,12 +829,16 @@ extension Home {
 
 
                         Spacer()
                         Spacer()
 
 
-                        Button {
-                            state.showProgressView()
-                            state.cancelBolus()
-                        } label: {
-                            Image(systemName: "xmark.app")
-                                .font(.system(size: 25))
+                        if state.bolusStatus == .inProgress {
+                            Button {
+                                state.showProgressView()
+                                state.cancelBolus()
+                            } label: {
+                                Image(systemName: "xmark.app")
+                                    .font(.system(size: 25))
+                            }
+                        } else if state.bolusStatus == .initiating {
+                            ProgressView()
                         }
                         }
                     }.padding(.horizontal, 10)
                     }.padding(.horizontal, 10)
                         .padding(.trailing, 8)
                         .padding(.trailing, 8)
@@ -995,6 +1004,9 @@ extension Home {
             .sheet(isPresented: $state.isLegendPresented) {
             .sheet(isPresented: $state.isLegendPresented) {
                 ChartLegendView(state: state)
                 ChartLegendView(state: state)
             }
             }
+            .sheet(isPresented: $showSnoozeSheet) {
+                SnoozeAlertsSheetView(resolver: resolver, isPresented: $showSnoozeSheet)
+            }
             // PUMP RELATED
             // PUMP RELATED
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
                 Button("Medtronic") { state.addPump(.minimed) }
                 Button("Medtronic") { state.addPump(.minimed) }
@@ -1109,17 +1121,28 @@ extension Home {
                 }
                 }
                 .tint(Color.tabBar)
                 .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) {
+                        guard state.enableQuickBolus else { return }
+                        let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                        impactHeavy.impactOccurred()
+                        Task {
+                            await state.loadQuickBolusSuggestions()
+                            if state.quickBolusHistory.isEmpty {
+                                showQuickBolusNoHistory = true
+                            } else {
+                                showQuickBolusPicker = true
+                            }
+                        }
                     }
                     }
-                )
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
             }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0)
                 .onChange(of: selectedTab) {
                 .onChange(of: selectedTab) {
                     if !settingsPath.isEmpty {
                     if !settingsPath.isEmpty {
@@ -1136,6 +1159,24 @@ extension Home {
                     CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
                     CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB"))
                 }
                 }
             }
             }
+            .sheet(isPresented: $showQuickBolusPicker) {
+                QuickPickBolusesView(
+                    suggestions: state.quickBolusHistory,
+                    onEnact: { amount in await state.enactQuickBolus(amount: amount) },
+                    isPresented: $showQuickBolusPicker
+                )
+            }
+            .alert(
+                String(localized: "No bolus history yet", comment: "Alert title when no quick-pick boluses history exists"),
+                isPresented: $showQuickBolusNoHistory
+            ) {
+                Button(String(localized: "OK"), role: .cancel) {}
+            } message: {
+                Text(String(
+                    localized: "Quick-Pick Boluses learns from your manual boluses over time. Once you've delivered a few boluses, it will suggest amounts based on what you typically enact at this time of day.",
+                    comment: "Alert body explaining that quick-pick boluses history is empty"
+                ))
+            }
         }
         }
     }
     }
 }
 }

+ 35 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesInfoView.swift

@@ -0,0 +1,35 @@
+import SwiftUI
+
+struct QuickPickBolusesInfoView: View {
+    @Binding var isPresented: Bool
+
+    var body: some View {
+        NavigationStack {
+            ScrollView {
+                Text(String(
+                    localized: "Quick-Pick Boluses looks at your manual boluses from the past 90 days and suggests the amounts you most commonly enact 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 select it, then slide to confirm. Your normal Face ID or Touch ID approval always applies.",
+                    comment: "Info sheet body explaining how quick-pick boluses scoring works"
+                ))
+                    .padding()
+            }
+            .navigationTitle(String(
+                localized: "About Quick-Pick Boluses",
+                comment: "Info sheet title for quick-pick boluses feature"
+            ))
+            .navigationBarTitleDisplayMode(.inline)
+
+            Button {
+                isPresented = false
+            } label: {
+                Text("Got it!", comment: "Dismiss button for Quick-Pick Boluses info sheet")
+                    .bold()
+                    .frame(maxWidth: .infinity, minHeight: 30, alignment: .center)
+            }
+            .buttonStyle(.bordered)
+            .padding([.horizontal, .bottom])
+            .padding(.top, 4)
+        }
+        .presentationDetents([.medium])
+        .presentationDragIndicator(.visible)
+    }
+}

+ 149 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/QuickPickBolusesView.swift

@@ -0,0 +1,149 @@
+import SwiftUI
+
+struct QuickPickBolusesView: View {
+    let suggestions: [Decimal]
+    let onEnact: (Decimal) async -> Bool
+    @Binding var isPresented: Bool
+
+    @State private var selectedAmount: Decimal?
+    @State private var showInfo = false
+    @State private var isEnacting = false
+    @State private var showAuthFailedAlert = false
+
+    var body: some View {
+        let titleText = String(localized: "Quick-Pick Boluses", comment: "Title of the quick-pick boluses sheet")
+        NavigationStack {
+            HStack(alignment: .top) {
+                VStack(alignment: .leading) {
+                    pillRow
+                        .padding(.top)
+
+                    Text(
+                        "Your most-used bolus amounts at similar times on similar days. Tap one to pick it.",
+                        comment: "Subtitle of the quick-pick boluses pill row"
+                    )
+                    .font(.subheadline)
+                    .foregroundStyle(.secondary)
+                    .multilineTextAlignment(.leading)
+                    .padding()
+
+                    Spacer()
+                }
+            }.padding(.horizontal)
+                .frame(maxWidth: .infinity)
+                .safeAreaInset(edge: .bottom) {
+                    VStack {
+                        SlideToConfirmView(
+                            label: String(
+                                localized: "Slide to Enact Bolus",
+                                comment: "Slide to confirm label for quick-pick boluses"
+                            ),
+                            isEnabled: selectedAmount != nil && !isEnacting
+                        ) {
+                            guard let amount = selectedAmount, !isEnacting else { return }
+                            isEnacting = true
+                            Task {
+                                let success = await onEnact(amount)
+                                await MainActor.run {
+                                    if success {
+                                        isPresented = false
+                                    } else {
+                                        isEnacting = false
+                                        showAuthFailedAlert = true
+                                    }
+                                }
+                            }
+                        }.padding(.horizontal)
+                    }
+                    .padding(.horizontal)
+                }
+                .navigationTitle(titleText)
+                .navigationBarTitleDisplayMode(.inline)
+                .toolbar {
+                    ToolbarItem(placement: .principal) {
+                        Text(titleText)
+                            .font(.title3.bold())
+                    }
+                    ToolbarItem(placement: .topBarTrailing) {
+                        Button {
+                            showInfo = true
+                        } label: {
+                            Image(systemName: "questionmark.circle")
+                        }
+                    }
+                }
+                .sheet(isPresented: $showInfo) {
+                    QuickPickBolusesInfoView(isPresented: $showInfo)
+                }
+                .alert(
+                    String(
+                        localized: "Could not authenticate",
+                        comment: "Alert title when biometric auth fails for quick-pick boluses"
+                    ),
+                    isPresented: $showAuthFailedAlert
+                ) {
+                    Button(String(localized: "OK"), role: .cancel) {}
+                } message: {
+                    Text(String(
+                        localized: "Face ID or Touch ID did not succeed. The bolus was not enacted.",
+                        comment: "Alert body when biometric auth fails for quick-pick boluses"
+                    ))
+                }
+        }
+        .presentationDetents([.height(320)])
+        .presentationDragIndicator(.visible)
+        .interactiveDismissDisabled(isEnacting)
+    }
+
+    private var displayedSuggestions: [Decimal] {
+        var seen = Set<Decimal>()
+        return suggestions.prefix(3).filter { seen.insert($0).inserted }.sorted()
+    }
+
+    private var pillRow: some View {
+        let pills = displayedSuggestions
+        let isCompact = pills.count < 2
+        return HStack(spacing: 16) {
+            if isCompact { Spacer() }
+
+            ForEach(pills, id: \.self) { amount in
+                bolusAmountPill(amount)
+                    .frame(maxWidth: isCompact ? 160 : .infinity)
+            }
+
+            if isCompact { Spacer() }
+        }
+        .padding(.horizontal)
+    }
+
+    private func bolusAmountPill(_ amount: Decimal) -> some View {
+        let isSelected = selectedAmount == amount
+        let formatted = Formatter.bolusFormatter.string(from: amount as NSDecimalNumber) ?? amount.description
+
+        return Button {
+            selectedAmount = amount
+        } label: {
+            HStack(alignment: .firstTextBaseline, spacing: 2) {
+                Text(formatted)
+                    .font(.title2.bold())
+                Text("U")
+                    .font(.title2)
+                    .foregroundStyle(isSelected ? .white.opacity(0.85) : .secondary)
+            }
+            .padding(.vertical, 12)
+            .padding(.horizontal, 24)
+            .frame(maxWidth: .infinity, minHeight: 44)
+            .background(isSelected ? Color.accentColor : Color(.secondarySystemFill))
+            .foregroundStyle(isSelected ? .white : .primary)
+            .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+            .overlay {
+                if isSelected {
+                    RoundedRectangle(cornerRadius: 18, style: .continuous)
+                        .strokeBorder(Color.accentColor, lineWidth: 2.5)
+                }
+            }
+        }
+        .buttonStyle(.plain)
+        .animation(.easeInOut(duration: 0.15), value: isSelected)
+    }
+}

+ 58 - 0
Trio/Sources/Modules/Home/View/QuickPickBoluses/SlideToConfirmView.swift

@@ -0,0 +1,58 @@
+import SwiftUI
+
+struct SlideToConfirmView: View {
+    let label: String
+    let isEnabled: Bool
+    let action: () -> Void
+
+    @State private var dragOffset: CGFloat = 0
+
+    private let thumbSize: CGFloat = 52
+    private let trackHeight: CGFloat = 56
+    private let completionThreshold: CGFloat = 0.85
+
+    var body: some View {
+        GeometryReader { geo in
+            let maxDrag = geo.size.width - thumbSize - 8
+            let progress = maxDrag > 0 ? dragOffset / maxDrag : 0
+
+            ZStack(alignment: .leading) {
+                Capsule()
+                    .fill(isEnabled ? Color.accentColor.opacity(0.25) : Color.secondary.opacity(0.2))
+
+                Text(label)
+                    .font(.headline)
+                    .foregroundStyle(isEnabled ? .white.opacity(1 - progress) : .secondary)
+                    .frame(maxWidth: .infinity)
+
+                RoundedRectangle(cornerRadius: thumbSize / 2)
+                    .fill(isEnabled ? Color.accentColor : Color.secondary.opacity(0.4))
+                    .frame(width: thumbSize, height: thumbSize)
+                    .overlay {
+                        Image(systemName: "chevron.right.2")
+                            .foregroundStyle(.white)
+                            .font(.system(size: 18, weight: .semibold))
+                    }
+                    .offset(x: 4 + dragOffset)
+                    .gesture(
+                        isEnabled ? DragGesture()
+                            .onChanged { value in
+                                dragOffset = min(max(0, value.translation.width), maxDrag)
+                            }
+                            .onEnded { _ in
+                                guard maxDrag > 0 else { return }
+                                if dragOffset >= maxDrag * completionThreshold {
+                                    UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
+                                    withAnimation(.spring()) { dragOffset = 0 }
+                                    action()
+                                } else {
+                                    withAnimation(.spring()) { dragOffset = 0 }
+                                }
+                            } : nil
+                    )
+            }
+            .frame(height: trackHeight)
+        }
+        .frame(height: trackHeight)
+    }
+}

+ 2 - 0
Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift

@@ -9,11 +9,13 @@ extension LiveActivitySettings {
         @Published var useLiveActivity = false
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
         @Published var lockScreenView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
+        @Published var displayGlucoseForecasts = false
         override func subscribe() {
         override func subscribe() {
             units = settingsManager.settings.units
             units = settingsManager.settings.units
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 }
+            subscribeSetting(\.displayGlucoseForecasts, on: $displayGlucoseForecasts) { displayGlucoseForecasts = $0 }
         }
         }
     }
     }
 }
 }

+ 34 - 0
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -9,6 +9,7 @@ extension LiveActivitySettings {
 
 
         @State private var shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
+        @State private var shouldDisplayHintGlucoseForecasts: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
         @State var selectedVerboseHint: AnyView?
         @State var hintLabel: String?
         @State var hintLabel: String?
@@ -83,6 +84,30 @@ extension LiveActivitySettings {
                     )
                     )
 
 
                     if state.useLiveActivity {
                     if state.useLiveActivity {
+                        SettingInputSection(
+                            decimalValue: $decimalPlaceholder,
+                            booleanValue: $state.displayGlucoseForecasts,
+                            shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                            selectedVerboseHint: Binding(
+                                get: { selectedVerboseHint },
+                                set: {
+                                    selectedVerboseHint = $0.map { AnyView($0) }
+                                    hintLabel = String(localized: "Display Glucose Forecasts")
+                                }
+                            ),
+                            units: state.units,
+                            type: .boolean,
+                            label: String(localized: "Display Glucose Forecasts"),
+                            miniHint: String(localized: "Display Glucose Forecasts made by the Oref algorithm."),
+                            verboseHint: VStack(alignment: .leading, spacing: 10) {
+                                Text("Default: OFF").bold()
+                                Text(
+                                    "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type."
+                                )
+                            },
+                            headerText: String(localized: "Display Glucose Forecasts")
+                        )
+
                         Section {
                         Section {
                             VStack {
                             VStack {
                                 Picker(
                                 Picker(
@@ -229,6 +254,15 @@ extension LiveActivitySettings {
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 )
                 )
             }
             }
+            .sheet(isPresented: $shouldDisplayHintGlucoseForecasts) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")
             .navigationTitle("Live Activity")

+ 2 - 2
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

@@ -220,9 +220,9 @@ struct LiveActivityWidgetConfiguration: BaseView {
                 let pointMarkColor = Trio.getDynamicGlucoseColor(
                 let pointMarkColor = Trio.getDynamicGlucoseColor(
                     glucoseValue: Decimal(data.glucoseLevel),
                     glucoseValue: Decimal(data.glucoseLevel),
                     highGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
                     highGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
-                        .settingsManager.settings.highGlucose : Decimal(220),
+                        .settingsManager.settings.high : Decimal(220),
                     lowGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
                     lowGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
-                        .settingsManager.settings.lowGlucose : Decimal(55),
+                        .settingsManager.settings.low : Decimal(55),
                     targetGlucose: Decimal(100),
                     targetGlucose: Decimal(100),
                     glucoseColorScheme: state.settingsManager.settings.glucoseColorScheme
                     glucoseColorScheme: state.settingsManager.settings.glucoseColorScheme
                 )
                 )

+ 0 - 307
Trio/Sources/Modules/Main/MainStateModel.swift

@@ -1,6 +1,5 @@
 import Combine
 import Combine
 import LoopKitUI
 import LoopKitUI
-import SwiftMessages
 import SwiftUI
 import SwiftUI
 import Swinject
 import Swinject
 
 
@@ -12,237 +11,6 @@ extension Main {
         @Published var modal: Modal?
         @Published var modal: Modal?
         @Published var secondaryModal: SecondaryModalWrapper?
         @Published var secondaryModal: SecondaryModalWrapper?
 
 
-        @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
-        private var timers: [TimeInterval: Timer] = [:]
-
-        private func showTriggeredView(
-            message: MessageContent,
-            interval _: TimeInterval,
-            config: SwiftMessages.Config,
-            view: MessageView
-        ) {
-            view.customConfigureTheme(
-                colorSchemePreference: colorSchemePreference
-            )
-            setupAction(message: message, view: view)
-
-            SwiftMessages.show(config: config, view: view)
-        }
-
-        // Add or replace timer for a specific TimeInterval
-        private func addOrReplaceTriggerTimer(message: MessageContent, config: SwiftMessages.Config, view: MessageView) {
-            let trigger = message.trigger as! UNTimeIntervalNotificationTrigger
-            guard trigger.timeInterval > 0 else { return }
-            let interval = trigger.timeInterval
-
-            SwiftMessages.hide(id: view.id)
-
-            // If a timer already exists for this interval, invalidate it
-            if let existingTimer = timers[interval] {
-                existingTimer.invalidate()
-            }
-
-            // Create a new timer with the provided interval
-            let newTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
-                self?.showTriggeredView(message: message, interval: interval, config: config, view: view)
-                self?.timers[interval] = nil
-            }
-
-            timers[interval] = newTimer
-        }
-
-        // Cancel all timers (optional cleanup method)
-        private func cancelAllTimers() {
-            timers.values.forEach { $0.invalidate() }
-            timers.removeAll()
-        }
-
-        private func setupPumpConfig() {
-            // display the pump configuration immediatly
-            if let pump = provider.deviceManager.pumpManager,
-               let bluetooth = provider.bluetoothProvider
-            {
-                let view = PumpConfig.PumpSettingsView(
-                    pumpManager: pump,
-                    bluetoothManager: bluetooth,
-                    completionDelegate: self
-                ).asAny()
-                router.mainSecondaryModalView.send(view)
-            }
-        }
-
-        private func setupButton(message _: MessageContent, view: MessageView) {
-            view.button?.setImage(UIImage(), for: .normal)
-            view.iconLabel = nil
-            let buttonImage = UIImage(systemName: "chevron.right")?.withTintColor(.white)
-            view.button?.setImage(buttonImage, for: .normal)
-            view.button?.backgroundColor = view.backgroundView.backgroundColor
-            view.button?.tintColor = view.iconImageView?.tintColor
-        }
-
-        private func setupAction(message: MessageContent, view: MessageView) {
-            switch message.action {
-            case .snooze:
-                setupButton(message: message, view: view)
-                view.buttonTapHandler = { _ in
-                    // Popup Snooze view when user taps on Glucose Notification
-                    SwiftMessages.hide()
-                    self.router.mainModalScreen.send(.snooze)
-                }
-            case .pumpConfig:
-                setupButton(message: message, view: view)
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                    self.setupPumpConfig()
-                }
-            default: // break
-                view.button?.setImage(UIImage(), for: .normal)
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                }
-            }
-        }
-
-        private func isApnPumpConfigAction(_ message: MessageContent) -> Bool {
-            if message.type != .error, message.action == .pumpConfig {
-                setupPumpConfig()
-                return true
-            }
-            return false
-        }
-
-        private func showAlertMessage(_ message: MessageContent) {
-            if message.useAPN, !alertPermissionsChecker.notificationsDisabled
-            {
-                showAPN(message)
-            } else {
-                showSwiftMessage(message)
-            }
-        }
-
-        private func showAPN(_ message: MessageContent) {
-            DispatchQueue.main.async {
-                self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
-                    $0.alertMessageNotification(message)
-                }
-            }
-        }
-
-        // Read the color scheme preference from UserDefaults; defaults to system default setting
-        @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
-
-        private func showSwiftMessage(_ message: MessageContent) {
-            if snoozeUntilDate > Date(), message.action == .snooze {
-                return
-            }
-
-            var config = SwiftMessages.defaultConfig
-            let view = MessageView.viewFromNib(layout: .cardView)
-
-            view.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
-            config.prefersStatusBarHidden = true
-
-            // Set id so that multiple notifications are not queued while waiting for user response; only the latest will be shown
-            if message.subtype == .glucose || message.subtype == .carb {
-                view.id = message.type.rawValue + message.subtype.rawValue
-            }
-
-            let titleContent: String
-
-            let iconName = UIApplication.shared.alternateIconName ?? "trioBlack"
-            let iconImage = UIImage(named: iconName) ?? UIImage()
-
-            view.configureContent(
-                title: "title",
-                body: message.content,
-                iconImage: nil,
-                iconText: nil,
-                buttonImage: nil,
-                buttonTitle: nil,
-                buttonTapHandler: nil
-            )
-
-            view.configureIcon(withSize: CGSize(width: 40, height: 40), contentMode: .scaleAspectFit)
-            view.iconImageView!.image = iconImage
-            view.iconImageView?.layer.cornerRadius = 10
-
-            view.customConfigureTheme(
-                colorSchemePreference: colorSchemePreference
-            )
-
-            view.iconImageView?.image = iconImage
-
-            switch message.type {
-            case .info,
-                 .other:
-                config.duration = .seconds(seconds: 5)
-                titleContent = message.title != "" ? message.title : String(localized: "Info", comment: "Info title")
-            case .warning:
-                config.duration = .forever
-                titleContent = message.title != "" ? message
-                    .title : String(localized: "Warning", comment: "Warning title")
-            case .error:
-                config.duration = .forever
-                titleContent = message.title != "" ? message
-                    .title : String(localized: "Error", comment: "Error title")
-            }
-
-            view.titleLabel?.text = titleContent
-            config.dimMode = .gray(interactive: true)
-
-            setupAction(message: message, view: view)
-            if message.trigger != nil {
-                addOrReplaceTriggerTimer(message: message, config: config, view: view)
-            }
-
-            guard message.type == .error || message.action != .pumpConfig, message.trigger == nil, !view.isHidden else { return }
-
-            SwiftMessages.show(config: config, view: view)
-        }
-
-        /*
-          Reclassification is needed for Medtronic pumps for 'Pump error:' RileyLink related messages.
-          For details, see https://discord.com/channels/1020905149037813862/1338245444186279946/1343469793013141525.
-          These messages are repeatedly displayed causing users to simply ignore them.
-          Reclassification of these Info type messages is based on APSManager.APSError enum values.
-          We reclassify APSError.pumpError and APSError.invalidPumpState as MessageType.info and MessageSubtype.pump.
-          This allows the user to disable these messages using using the 'Trio Notification' -> 'Always Notify Pump' setting.
-          MessageType.error messagges are always displayed to the user and the user cannot disable them.
-          Other APSManager.APSError remain as MessageType.info which allows users to disable them
-          using the 'Trio Notification' -> 'Always Notify Algorithm' setting.
-         */
-
-        func reclassifyInfoNotification(_ message: inout MessageContent) {
-            if message.title == "" {
-                switch message.type {
-                case .info:
-                    if message.content.range(of: "error", options: .caseInsensitive) != nil || message.content
-                        .range(of: String(localized: "Error"), options: .caseInsensitive) != nil
-                    {
-                        message.title = String(localized: "Error", comment: "Error title")
-                    } else {
-                        message.title = String(localized: "Info", comment: "Info title")
-                    }
-                    if APSError.pumpWarningMatches(message: message.content) {
-                        message.subtype = .pump
-                        let lastLoopMinutes = Int((Date().timeIntervalSince(apsManager.lastLoopDate) - 30) / 60) + 1
-                        if lastLoopMinutes > 10 {
-                            message.type = .error
-                        }
-                    } else if APSError.pumpErrorMatches(message: message.content) {
-                        message.subtype = .pump
-                        message.type = .error
-                    }
-                case .warning:
-                    message.title = String(localized: "Warning", comment: "Warning title")
-                case .error:
-                    message.title = String(localized: "Error", comment: "Error title")
-                case .other:
-                    message.title = String(localized: "Info", comment: "Info title")
-                }
-            }
-        }
-
         override func subscribe() {
         override func subscribe() {
             router.mainModalScreen
             router.mainModalScreen
                 .map { $0?.modal(resolver: self.resolver!) }
                 .map { $0?.modal(resolver: self.resolver!) }
@@ -258,17 +26,6 @@ extension Main {
                 }
                 }
                 .store(in: &lifetime)
                 .store(in: &lifetime)
 
 
-            router.alertMessage
-                .receive(on: DispatchQueue.main)
-                .sink { message in
-                    guard !self.isApnPumpConfigAction(message) else { return }
-                    var reclassifyMessage = message
-                    self.reclassifyInfoNotification(&reclassifyMessage)
-                    guard self.router.allowNotify(reclassifyMessage, self.settingsManager.settings) else { return }
-                    self.showAlertMessage(reclassifyMessage)
-                }
-                .store(in: &lifetime)
-
             router.mainSecondaryModalView
             router.mainSecondaryModalView
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
                 .sink { view in
                 .sink { view in
@@ -286,67 +43,3 @@ extension Main {
         }
         }
     }
     }
 }
 }
-
-extension MessageView {
-    func currentColorScheme() -> ColorScheme {
-        let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
-        return userInterfaceStyle == .dark ? .dark : .light
-    }
-
-    func customConfigureTheme(colorSchemePreference: ColorSchemeOption) {
-        let defaultSystemColorScheme = currentColorScheme()
-        var backgroundColor = UIColor.systemBackground
-        var foregroundColor = UIColor.white
-        let ApnBackground = UIColor(named: "ApnBackground") ?? UIColor.lightGray
-        let iOSlightTrioDark = UIColor(named: "ApnBackgroundLightDark") ?? UIColor.lightGray
-
-        switch colorSchemePreference {
-        case .systemDefault:
-            backgroundColor = ApnBackground
-            foregroundColor = UIColor.label
-        case .dark:
-            backgroundColor = defaultSystemColorScheme == .light ? iOSlightTrioDark : ApnBackground
-            foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
-        case .light:
-            backgroundColor = defaultSystemColorScheme == .light ? ApnBackground : UIColor.gray
-            foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
-        }
-
-        iconImageView?.tintColor = foregroundColor
-        backgroundView.backgroundColor = backgroundColor
-        titleLabel?.textColor = foregroundColor
-        bodyLabel?.textColor = foregroundColor
-        iconImageView?.isHidden = iconImageView?.image == nil
-
-        backgroundView.layer.cornerRadius = 25
-
-        let adjustedFont = UIFont.systemFont(ofSize: 13.0, weight: .bold)
-        let preferredTitleFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: adjustedFont)
-        let preferredBodyFont = UIFont.preferredFontforStyle(forTextStyle: .footnote)
-        // Set the title and body font to the dynamic type sizes
-        titleLabel?.adjustsFontForContentSizeCategory = true
-        titleLabel?.font = preferredTitleFont
-        bodyLabel?.adjustsFontForContentSizeCategory = true
-        bodyLabel?.font = preferredBodyFont
-        // Set custom colors for title and body text
-        titleLabel?.textColor = foregroundColor
-        bodyLabel?.textColor = foregroundColor
-    }
-}
-
-@available(iOS 16.0, *)
-extension Main.StateModel: CompletionDelegate {
-    func completionNotifyingDidComplete(_: CompletionNotifying) {
-        // close the window
-        router.mainSecondaryModalView.send(nil)
-    }
-}
-
-// Extension to convert SwiftUI TextStyle to UIFont
-extension UIFont {
-    static func preferredFontforStyle(forTextStyle: UIFont.TextStyle) -> UIFont {
-        let uiFontMetrics = UIFontMetrics.default
-        let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: forTextStyle)
-        return uiFontMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0))
-    }
-}

+ 5 - 1
Trio/Sources/Modules/Main/View/MainRootView.swift

@@ -9,6 +9,10 @@ extension Main {
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
         @Environment(AppState.self) var appState
 
 
+        private var modalScheduler: TrioModalAlertScheduler {
+            resolver.resolve(TrioAlertManager.self)!.modalScheduler
+        }
+
         var body: some View {
         var body: some View {
             router.view(for: .home)
             router.view(for: .home)
                 .sheet(item: $state.modal) { modal in
                 .sheet(item: $state.modal) { modal in
@@ -18,7 +22,7 @@ extension Main {
                 .sheet(item: $state.secondaryModal) { wrapper in
                 .sheet(item: $state.secondaryModal) { wrapper in
                     wrapper.view
                     wrapper.view
                 }
                 }
-
+                .trioAlerts(modalScheduler)
                 .onAppear(perform: configureView)
                 .onAppear(perform: configureView)
                 .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
                 .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
         }
         }

+ 0 - 0
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.