Explorar o código

Merge branch 'alpha' into Crowdin

Jon B.M %!s(int64=4) %!d(string=hai) anos
pai
achega
1b8fe939f9
Modificáronse 39 ficheiros con 945 adicións e 284 borrados
  1. 0 97
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/Common/NotificationHelper.swift
  2. 1 42
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/Common/Settings/UserDefaults+Alarmsettings.swift
  3. 0 26
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterManager.swift
  4. 2 6
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/LibreTransmitterManager+UI.swift
  5. 0 43
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift
  6. 3 18
      Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/Views/Settings/SettingsView.swift
  7. 76 0
      FreeAPS.xcodeproj/project.pbxproj
  8. 8 1
      FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json
  9. 4 0
      FreeAPS/Sources/APS/CGM/AppGroupSource.swift
  10. 4 0
      FreeAPS/Sources/APS/CGM/DexcomSource.swift
  11. 4 0
      FreeAPS/Sources/APS/CGM/GlucoseSimulatorSource.swift
  12. 9 1
      FreeAPS/Sources/APS/CGM/GlucoseSource.swift
  13. 7 0
      FreeAPS/Sources/APS/CGM/LibreTransmitterSource.swift
  14. 5 1
      FreeAPS/Sources/APS/FetchGlucoseManager.swift
  15. 31 0
      FreeAPS/Sources/APS/Storage/GlucoseStorage.swift
  16. 1 26
      FreeAPS/Sources/Application/AppDelegate.swift
  17. 2 1
      FreeAPS/Sources/Application/FreeAPSApp.swift
  18. 2 1
      FreeAPS/Sources/Assemblies/ServiceAssembly.swift
  19. 38 0
      FreeAPS/Sources/Models/FreeAPSSettings.swift
  20. 2 1
      FreeAPS/Sources/Modules/Base/BaseStateModel.swift
  21. 4 2
      FreeAPS/Sources/Modules/Home/HomeStateModel.swift
  22. 2 0
      FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  23. 17 2
      FreeAPS/Sources/Modules/Home/View/HomeRootView.swift
  24. 0 5
      FreeAPS/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift
  25. 0 8
      FreeAPS/Sources/Modules/Main/View/MainRootView.swift
  26. 5 0
      FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigDataFlow.swift
  27. 3 0
      FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigProvider.swift
  28. 46 0
      FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigStateModel.swift
  29. 64 0
      FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift
  30. 1 0
      FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift
  31. 5 0
      FreeAPS/Sources/Modules/Snooze/SnoozeDataFlow.swift
  32. 3 0
      FreeAPS/Sources/Modules/Snooze/SnoozeProvider.swift
  33. 14 0
      FreeAPS/Sources/Modules/Snooze/SnoozeStateModel.swift
  34. 162 0
      FreeAPS/Sources/Modules/Snooze/View/SnoozeRootView.swift
  35. 6 0
      FreeAPS/Sources/Router/Screen.swift
  36. 25 2
      FreeAPS/Sources/Services/Calendar/CalendarManager.swift
  37. 15 0
      FreeAPS/Sources/Services/Network/NightscoutManager.swift
  38. 1 1
      FreeAPS/Sources/Services/SettingsManager/SettingsManager.swift
  39. 373 0
      FreeAPS/Sources/Services/UserNotifiactions/UserNotificationsManager.swift

+ 0 - 97
Dependencies/LibreTransmitter/Sources/LibreTransmitter/Common/NotificationHelper.swift

@@ -6,7 +6,6 @@
 //  Copyright © 2019 Bjørn Inge Berg. All rights reserved.
 //
 
-import AudioToolbox
 import Foundation
 import HealthKit
 import UserNotifications
@@ -32,22 +31,6 @@ public enum NotificationHelper {
         case restoredState = "no.bjorninge.miaomiao.state-notification"
     }
 
-    public static func playSoundIfNeeded(count: Int = 3) {
-        if UserDefaults.standard.mmGlucoseAlarmsVibrate {
-            playSound(times: count)
-        }
-    }
-
-    private static func playSound(times: Int) {
-        guard times > 0 else {
-            return
-        }
-
-        AudioServicesPlaySystemSoundWithCompletion(1336) {
-            playSound(times: times - 1)
-        }
-    }
-
     public static func GlucoseUnitIsSupported(unit: HKUnit) -> Bool {
         [HKUnit.milligramsPerDeciliter, HKUnit.millimolesPerLiter].contains(unit)
     }
@@ -149,34 +132,6 @@ public enum NotificationHelper {
         }
     }
 
-    private static var glucoseNotifyCalledCount = 0
-
-    public static func sendGlucoseNotitifcationIfNeeded(glucose: LibreGlucose, oldValue: LibreGlucose?, trend: GlucoseTrend?, battery: String?) {
-        glucoseNotifyCalledCount &+= 1
-
-        let shouldSendGlucoseAlternatingTimes = glucoseNotifyCalledCount != 0 && UserDefaults.standard.mmNotifyEveryXTimes != 0
-
-        let shouldSend = UserDefaults.standard.mmAlwaysDisplayGlucose || glucoseNotifyCalledCount == 1 || (shouldSendGlucoseAlternatingTimes && glucoseNotifyCalledCount % UserDefaults.standard.mmNotifyEveryXTimes == 0)
-
-        let schedules = UserDefaults.standard.glucoseSchedules
-
-        let alarm = schedules?.getActiveAlarms(glucose.glucoseDouble) ?? .none
-        let isSnoozed = GlucoseScheduleList.isSnoozed()
-
-        let transmitterBattery = UserDefaults.standard.mmShowTransmitterBattery && battery != nil ? battery : nil
-
-        logger.debug("dabear:: glucose alarmtype is \(String(describing:alarm))")
-        // We always send glucose notifications when alarm is active,
-        // even if glucose notifications are disabled in the UI
-
-        if shouldSend || alarm.isAlarming() {
-            sendGlucoseNotitifcation(glucose: glucose, oldValue: oldValue, alarm: alarm, isSnoozed: isSnoozed, trend: trend, transmitterBattery: transmitterBattery)
-        } else {
-            logger.debug("dabear:: not sending glucose, shouldSend and alarmIsActive was false")
-            return
-        }
-    }
-
     private static func addRequest(identifier: Identifiers, content: UNMutableNotificationContent, deleteOld: Bool = false) {
         let center = UNUserNotificationCenter.current()
         //content.sound = UNNotificationSound.
@@ -197,58 +152,6 @@ public enum NotificationHelper {
             logger.debug("dabear:: sending \(identifier.rawValue) notification")
         }
     }
-    private static func sendGlucoseNotitifcation(glucose: LibreGlucose, oldValue: LibreGlucose?, alarm: GlucoseScheduleAlarmResult = .none, isSnoozed: Bool = false, trend: GlucoseTrend?, transmitterBattery: String?) {
-        ensureCanSendGlucoseNotification { _ in
-            let content = UNMutableNotificationContent()
-            let glucoseDesc = glucose.description
-            var titles = [String]()
-            var body = [String]()
-            var body2 = [String]()
-            switch alarm {
-            case .none:
-                titles.append(LocalizedString("Glucose", comment: "Glucose"))
-            case .low:
-                titles.append(LocalizedString("LOWALERT!", comment: "LOWALERT!"))
-            case .high:
-                titles.append(LocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
-            }
-
-            if isSnoozed {
-                titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
-            } else if alarm.isAlarming() {
-                content.sound = .default
-                content.userInfo = ["action": "snooze"]
-                playSoundIfNeeded()
-            }
-            titles.append(glucoseDesc)
-
-            body.append(String(format: NSLocalizedString("Glucose: %@", comment: "Glucose: %@"), glucoseDesc))
-
-            if let oldValue = oldValue {
-                body.append( LibreGlucose.glucoseDiffDesc(oldValue: oldValue, newValue: glucose))
-            }
-
-            if let trendSymbol = trend?.symbol {
-                body.append("\(trendSymbol)")
-            }
-
-            if let transmitterBattery = transmitterBattery {
-                body2.append(String(format: NSLocalizedString("Transmitter: %@%%", comment: "Transmitter: %@%%"), transmitterBattery))
-            }
-
-            //these are texts that naturally fit on their own line in the body
-            var body2s = ""
-            if !body2.isEmpty {
-                body2s = "\n" + body2.joined(separator: "\n")
-            }
-
-            content.title = titles.joined(separator: " ")
-            content.body = body.joined(separator: ", ") + body2s
-            addRequest(identifier: .glucocoseNotifications,
-                       content: content,
-                       deleteOld: true)
-        }
-    }
 
     public enum CalibrationMessage: String {
         case starting = "Calibrating sensor, please stand by!"

+ 1 - 42
Dependencies/LibreTransmitter/Sources/LibreTransmitter/Common/Settings/UserDefaults+Alarmsettings.swift

@@ -12,19 +12,13 @@ import HealthKit
 extension UserDefaults {
     private enum Key: String {
         case glucoseSchedules = "no.bjorninge.glucoseschedules"
-
-        case mmAlwaysDisplayGlucose = "no.bjorninge.mmAlwaysDisplayGlucose"
-        case mmNotifyEveryXTimes = "no.bjorninge.mmNotifyEveryXTimes"
-        case mmGlucoseAlarmsVibrate = "no.bjorninge.mmGlucoseAlarmsVibrate"
         case mmAlertLowBatteryWarning = "no.bjorninge.mmLowBatteryWarning"
         case mmAlertInvalidSensorDetected = "no.bjorninge.mmInvalidSensorDetected"
-        //case mmAlertalarmNotifications
         case mmAlertNewSensorDetected = "no.bjorninge.mmNewSensorDetected"
         case mmAlertNoSensorDetected = "no.bjorninge.mmNoSensorDetected"
         case mmGlucoseUnit = "no.bjorninge.mmGlucoseUnit"
         case mmAlertSensorSoonExpire = "no.bjorninge.mmAlertSensorSoonExpire"
         case mmSnoozedUntil = "no.bjorninge.mmSnoozedUntil"
-        case mmShowTransmitterBattery = "no.bjorninge.mmShowTransmitterBattery"
     }
     /*
      case always
@@ -42,23 +36,6 @@ extension UserDefaults {
         return nil
     }
 
-    var mmAlwaysDisplayGlucose: Bool {
-        get {
-            optionalBool(forKey: Key.mmAlwaysDisplayGlucose.rawValue) ?? true
-        }
-        set {
-            set(newValue, forKey: Key.mmAlwaysDisplayGlucose.rawValue)
-        }
-    }
-    var mmNotifyEveryXTimes: Int {
-        get {
-            integer(forKey: Key.mmNotifyEveryXTimes.rawValue)
-        }
-        set {
-            set(newValue, forKey: Key.mmNotifyEveryXTimes.rawValue)
-        }
-    }
-
     var mmAlertLowBatteryWarning: Bool {
         get {
             optionalBool(forKey: Key.mmAlertLowBatteryWarning.rawValue) ?? true
@@ -103,26 +80,8 @@ extension UserDefaults {
         }
     }
 
-    var mmGlucoseAlarmsVibrate: Bool {
-        get {
-            optionalBool(forKey: Key.mmGlucoseAlarmsVibrate.rawValue) ?? true
-        }
-        set {
-            set(newValue, forKey: Key.mmGlucoseAlarmsVibrate.rawValue)
-        }
-    }
-
-    var mmShowTransmitterBattery: Bool {
-        get {
-            optionalBool(forKey: Key.mmShowTransmitterBattery.rawValue) ?? true
-        }
-        set {
-            set(newValue, forKey: Key.mmShowTransmitterBattery.rawValue)
-        }
-    }
-
     var allNotificationToggles: [Bool] {
-        [mmAlwaysDisplayGlucose, mmAlertLowBatteryWarning, mmAlertInvalidSensorDetected, mmAlertNewSensorDetected, mmAlertNoSensorDetected, mmAlertWillSoonExpire, mmGlucoseAlarmsVibrate, mmShowTransmitterBattery]
+        [mmAlertLowBatteryWarning, mmAlertInvalidSensorDetected, mmAlertNewSensorDetected, mmAlertNoSensorDetected, mmAlertWillSoonExpire]
     }
 
     //intentionally only supports mgdl and mmol

+ 0 - 26
Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterManager.swift

@@ -110,9 +110,6 @@ public final class LibreTransmitterManager: LibreTransmitterDelegate {
 
     public private(set) var lastConnected: Date?
 
-    public private(set) var alarmStatus = AlarmStatus()
-
-
     public private(set) var latestPrediction: LibreGlucose?
     public private(set) var latestBackfill: LibreGlucose? {
         willSet(newValue) {
@@ -123,29 +120,6 @@ public final class LibreTransmitterManager: LibreTransmitterDelegate {
             var trend: GlucoseTrend?
             let oldValue = latestBackfill
 
-            defer {
-                logger.debug("dabear:: sending glucose notification")
-                NotificationHelper.sendGlucoseNotitifcationIfNeeded(glucose: newValue,
-                                                                    oldValue: oldValue,
-                                                                    trend: trend,
-                                                                    battery: batteryString)
-
-                //once we have a new glucose value, we can update the isalarming property
-                if let activeAlarms = UserDefaults.standard.glucoseSchedules?.getActiveAlarms(newValue.glucoseDouble) {
-                    DispatchQueue.main.async {
-                        self.alarmStatus.isAlarming = ([.high,.low].contains(activeAlarms))
-                        self.alarmStatus.glucoseScheduleAlarmResult = activeAlarms
-                    }
-                } else {
-                    DispatchQueue.main.async {
-                    self.alarmStatus.isAlarming = false
-                    self.alarmStatus.glucoseScheduleAlarmResult = .none
-                    }
-                }
-
-
-            }
-
             logger.debug("dabear:: latestBackfill set, newvalue is \(newValue.description)")
 
             if let oldValue = oldValue {

+ 2 - 6
Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/LibreTransmitterManager+UI.swift

@@ -62,14 +62,12 @@ public struct LibreTransmitterSettingsView: UIViewControllerRepresentable {
     private let glucoseUnit: HKUnit
     private let delete: (() -> Void)?
     private let completion: (() -> Void)?
-    private let openSnooze: Bool
 
-    public init(manager: LibreTransmitterManager, openSnooze: Bool = false, glucoseUnit: HKUnit, delete: (() -> Void)? = nil , completion: (() -> Void)? = nil) {
+    public init(manager: LibreTransmitterManager, glucoseUnit: HKUnit, delete: (() -> Void)? = nil , completion: (() -> Void)? = nil) {
         self.manager = manager
         self.glucoseUnit = glucoseUnit
         self.delete = delete
         self.completion = completion
-        self.openSnooze = openSnooze
     }
 
     public func makeUIViewController(context: Context) -> UIViewController {
@@ -78,14 +76,12 @@ public struct LibreTransmitterSettingsView: UIViewControllerRepresentable {
 
         let settings = SettingsView.asHostedViewController(
             glucoseUnit: glucoseUnit,
-            openSnooze: openSnooze,
             //displayGlucoseUnitObservable: displayGlucoseUnitObservable,
             notifyComplete: doneNotifier,
             notifyDelete: wantToTerminateNotifier,
             transmitterInfoObservable: manager.transmitterInfoObservable,
             sensorInfoObervable: manager.sensorInfoObservable,
-            glucoseInfoObservable: manager.glucoseInfoObservable,
-            alarmStatus: manager.alarmStatus
+            glucoseInfoObservable: manager.glucoseInfoObservable
         )
 
         let nav = SettingsNavigationViewController(rootViewController: settings)

+ 0 - 43
Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift

@@ -36,11 +36,6 @@ struct NotificationSettingsView: View {
 
 
     private enum Key: String {
-        //case glucoseSchedules = "no.bjorninge.glucoseschedules"
-
-        case mmAlwaysDisplayGlucose = "no.bjorninge.mmAlwaysDisplayGlucose"
-        case mmNotifyEveryXTimes = "no.bjorninge.mmNotifyEveryXTimes"
-        case mmGlucoseAlarmsVibrate = "no.bjorninge.mmGlucoseAlarmsVibrate"
         case mmAlertLowBatteryWarning = "no.bjorninge.mmLowBatteryWarning"
         case mmAlertInvalidSensorDetected = "no.bjorninge.mmInvalidSensorDetected"
         //case mmAlertalarmNotifications
@@ -49,30 +44,16 @@ struct NotificationSettingsView: View {
 
         case mmAlertSensorSoonExpire = "no.bjorninge.mmAlertSensorSoonExpire"
 
-        case mmShowTransmitterBattery = "no.bjorninge.mmShowTransmitterBattery"
-
         //handle specially:
         case mmGlucoseUnit = "no.bjorninge.mmGlucoseUnit"
     }
 
-
-
-
-
-    @AppStorage(Key.mmAlwaysDisplayGlucose.rawValue) var mmAlwaysDisplayGlucose: Bool = true
-    @AppStorage(Key.mmNotifyEveryXTimes.rawValue) var mmNotifyEveryXTimes: Int = 0
-    @AppStorage(Key.mmShowTransmitterBattery.rawValue) var mmShowTransmitterBattery: Bool = true
-
-
-
     @AppStorage(Key.mmAlertLowBatteryWarning.rawValue) var mmAlertLowBatteryWarning: Bool = true
     @AppStorage(Key.mmAlertInvalidSensorDetected.rawValue) var mmAlertInvalidSensorDetected: Bool = true
     @AppStorage(Key.mmAlertNewSensorDetected.rawValue) var mmAlertNewSensorDetected: Bool = true
     @AppStorage(Key.mmAlertNoSensorDetected.rawValue) var mmAlertNoSensorDetected: Bool = true
     @AppStorage(Key.mmAlertSensorSoonExpire.rawValue) var mmAlertSensorSoonExpire: Bool = true
 
-    @AppStorage(Key.mmGlucoseAlarmsVibrate.rawValue) var mmGlucoseAlarmsVibrate: Bool = true
-
     //especially handled mostly for backward compat
     @AppStorage(Key.mmGlucoseUnit.rawValue) var mmGlucoseUnit: String = ""
 
@@ -83,28 +64,6 @@ struct NotificationSettingsView: View {
 
     static let formatter = NumberFormatter()
 
-    var glucoseVisibilitySection : some View {
-        Section(header: Text("Glucose Notification visibility") ) {
-            Toggle("Always Notify Glucose", isOn: $mmAlwaysDisplayGlucose)
-
-            HStack {
-                Text("Notify per reading")
-                TextField("", value: $mmNotifyEveryXTimes, formatter: Self.formatter)
-                    .multilineTextAlignment(.center)
-                    .disabled(true)
-                    .frame(minWidth: 15, maxWidth: 60)
-                    .textFieldStyle(RoundedBorderTextFieldStyle())
-                Stepper("Value", value: $mmNotifyEveryXTimes, in: 0...9)
-                    .labelsHidden()
-
-            }.clipped()
-
-            Toggle("Adds Transmitter Battery", isOn: $mmShowTransmitterBattery)
-            Toggle("Also play alert sound", isOn: $mmGlucoseAlarmsVibrate)
-
-        }
-    }
-
     var additionalNotificationsSection : some View {
         Section(header: Text("Additional notification types")) {
             Toggle("Low battery", isOn:$mmAlertLowBatteryWarning)
@@ -132,8 +91,6 @@ struct NotificationSettingsView: View {
 
     var body: some View {
         List {
-            
-            glucoseVisibilitySection
             additionalNotificationsSection
 
             miscSection

+ 3 - 18
Dependencies/LibreTransmitter/Sources/LibreTransmitter/LibreTransmitterUI/Views/Settings/SettingsView.swift

@@ -94,8 +94,6 @@ class SettingsModel : ObservableObject {
 }
 
 struct SettingsView: View {
-    @State var openSnooze = false
-
     //@ObservedObject private var displayGlucoseUnitObservable: DisplayGlucoseUnitObservable
     @ObservedObject private var transmitterInfo: LibreTransmitter.TransmitterInfo
     @ObservedObject private var sensorInfo: LibreTransmitter.SensorInfo
@@ -109,7 +107,6 @@ struct SettingsView: View {
     //most of the settings are now retrieved from the cgmmanager observables instead
     @StateObject var model = SettingsModel()
     @State private var presentableStatus: StatusMessage?
-    @ObservedObject var alarmStatus: LibreTransmitter.AlarmStatus
 
     @State private var showingDestructQuestion = false
     @State private var showingExporter = false
@@ -118,18 +115,15 @@ struct SettingsView: View {
 
     static func asHostedViewController(
         glucoseUnit: HKUnit,
-        openSnooze: Bool,
         //displayGlucoseUnitObservable: DisplayGlucoseUnitObservable,
         notifyComplete: GenericObservableObject,
         notifyDelete: GenericObservableObject,
         transmitterInfoObservable:LibreTransmitter.TransmitterInfo,
         sensorInfoObervable: LibreTransmitter.SensorInfo,
-        glucoseInfoObservable: LibreTransmitter.GlucoseInfo,
-        alarmStatus: LibreTransmitter.AlarmStatus) -> UIHostingController<SettingsView> {
+        glucoseInfoObservable: LibreTransmitter.GlucoseInfo) -> UIHostingController<SettingsView> {
         UIHostingController(rootView: self.init(
             //displayGlucoseUnitObservable: displayGlucoseUnitObservable,
-            openSnooze: openSnooze,
-            transmitterInfo: transmitterInfoObservable, sensorInfo: sensorInfoObervable, glucoseMeasurement: glucoseInfoObservable, notifyComplete: notifyComplete, notifyDelete: notifyDelete, alarmStatus: alarmStatus, glucoseUnit: glucoseUnit
+            transmitterInfo: transmitterInfoObservable, sensorInfo: sensorInfoObervable, glucoseMeasurement: glucoseInfoObservable, notifyComplete: notifyComplete, notifyDelete: notifyDelete, glucoseUnit: glucoseUnit
 
         ))
     }
@@ -189,19 +183,10 @@ struct SettingsView: View {
 
     var snoozeSection: some View {
         Section {
-            NavigationLink(destination: SnoozeView(isAlarming: $alarmStatus.isAlarming, activeAlarms: $alarmStatus.glucoseScheduleAlarmResult), isActive: $openSnooze) {
-                if alarmStatus.isAlarming {
-                    Text("Snooze Alerts").frame(alignment: .center)
-                        .padding(.top, 30)
-                        .padding(.bottom, 30)
-                } else {
-                    Text("Snooze Alerts").frame(alignment: .center)
-                }
-            }
+            Text("Snooze Alerts").frame(alignment: .center)
         }
     }
 
-
     var measurementSection : some View {
         Section(header: Text("Last measurement")) {
             if glucoseUnit == .millimolesPerLiter {

+ 76 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -8,8 +8,10 @@
 
 /* Begin PBXBuildFile section */
 		041D1E995A6AE92E9289DC49 /* BolusDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* BolusDataFlow.swift */; };
+		0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5822B15939E719628E9FF7C /* SnoozeRootView.swift */; };
 		0CEA2EA070AB041AF3E3745B /* BolusRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A0C32B0DAB52726EF9B6D9 /* BolusRootView.swift */; };
 		0D9A5E34A899219C5C4CDFAF /* DataTableStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* DataTableStateModel.swift */; };
+		0F7A65FBD2CD8D6477ED4539 /* NotificationsConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E625985B47742D498CB1681A /* NotificationsConfigProvider.swift */; };
 		17A9D0899046B45E87834820 /* CREditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8D5F457B5AFF763F8CF3DF /* CREditorProvider.swift */; };
 		1927C8E62744606D00347C69 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1927C8E82744606D00347C69 /* InfoPlist.strings */; };
 		198377D2266BFFF6004DE65E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 198377D4266BFFF6004DE65E /* Localizable.strings */; };
@@ -19,6 +21,7 @@
 		28089E07169488CF6DCC2A31 /* AddCarbsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FC1CFD647CF34508AF9A3B /* AddCarbsRootView.swift */; };
 		2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97F14812C1AFED3621165A5 /* PumpSettingsEditorProvider.swift */; };
 		3083261C4B268E353F36CD0B /* AutotuneConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DCCCCE633F5E98E41B0CD3C /* AutotuneConfigDataFlow.swift */; };
+		3171D2818C7C72CD1584BB5E /* NotificationsConfigStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2C6489D29ECCCAD78E0721 /* NotificationsConfigStateModel.swift */; };
 		320D030F724170A637F06D50 /* CalibrationsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212E8BFE6D66EE65AA26A114 /* CalibrationsProvider.swift */; };
 		33E198D3039045D98C3DC5D4 /* AddCarbsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39E7C997E56DAF8D28D09014 /* AddCarbsStateModel.swift */; };
 		3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE0725C9D32E00A708ED /* BaseView.swift */; };
@@ -176,6 +179,7 @@
 		38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E44533274E411700EC9A94 /* Disk+Errors.swift */; };
 		38E87401274F77E400975559 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E873FD274F761800975559 /* CoreNFC.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */; settings = {ATTRIBUTES = (Weak, ); }; };
+		38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E87407274F9AD000975559 /* UserNotificationsManager.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
@@ -213,12 +217,15 @@
 		6632A0DC746872439A858B44 /* ISFEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */; };
 		69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223EC0494F55A91E3EA69EF4 /* BolusStateModel.swift */; };
 		69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA5E04A2761F6EEA6568E1 /* CREditorStateModel.swift */; };
+		6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */; };
 		6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72778B68C3004F71F6E79BDC /* PumpSettingsEditorStateModel.swift */; };
 		6EADD581738D64431902AC0A /* LibreConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EBA7C03C26FCC67E16D798 /* LibreConfigProvider.swift */; };
+		6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAE81192B118804DCD23034 /* SnoozeProvider.swift */; };
 		711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96653287EDB276A111288305 /* ManualTempBasalDataFlow.swift */; };
 		72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */; };
 		7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */; };
 		7F7B756BE8543965D9FDF1A2 /* DataTableDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A401509D21F7F35D4E109EDA /* DataTableDataFlow.swift */; };
+		8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */; };
 		88AB39B23C9552BD6E0C9461 /* ISFEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */; };
 		891DECF7BC20968D7F566161 /* AutotuneConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EF98E22A39CD656A230704 /* AutotuneConfigProvider.swift */; };
 		8B759CFCF47B392BB365C251 /* BasalProfileEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67F94DD2853CF42BA4E30616 /* BasalProfileEditorDataFlow.swift */; };
@@ -242,6 +249,7 @@
 		BF1667ADE69E4B5B111CECAE /* ManualTempBasalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */; };
 		C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; };
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
+		CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */; };
 		D2165E9D78EFF692C1DED1C6 /* AddTempTargetDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8A42073A2D03A278914448 /* AddTempTargetDataFlow.swift */; };
 		D6D02515BBFBE64FEBE89856 /* DataTableRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881E04BA5E0A003DE8E0A9C6 /* DataTableRootView.swift */; };
 		D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */; };
@@ -258,6 +266,7 @@
 		E13B7DAB2A435F57066AF02E /* TargetsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */; };
 		E25073BC86C11C3D6A42F5AC /* CalibrationsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47DFCE895C930F784EF11843 /* CalibrationsStateModel.swift */; };
 		E39E418C56A5A46B61D960EE /* ConfigEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5B4F8B4194BB7E260EF251 /* ConfigEditorStateModel.swift */; };
+		E3A08AAE59538BC8A8ABE477 /* NotificationsConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3260468377DA9DB4DEE9AF6D /* NotificationsConfigDataFlow.swift */; };
 		E4984C5262A90469788754BB /* PreferencesEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8BA8533F56BC55748CA877 /* PreferencesEditorProvider.swift */; };
 		E97285ED9B814CD5253C6658 /* AddCarbsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F48C3AC770D4CCD0EA2B0C2 /* AddCarbsDataFlow.swift */; };
 		E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */; };
@@ -348,10 +357,14 @@
 		198377E4266C13D2004DE65E /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
 		199732B4271B72DD00129A3F /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = "<group>"; };
 		199732B5271B9EE900129A3F /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
+		1CAE81192B118804DCD23034 /* SnoozeProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeProvider.swift; sourceTree = "<group>"; };
 		212E8BFE6D66EE65AA26A114 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = "<group>"; };
 		223EC0494F55A91E3EA69EF4 /* BolusStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BolusStateModel.swift; sourceTree = "<group>"; };
+		22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigRootView.swift; sourceTree = "<group>"; };
 		2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = "<group>"; };
 		2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = "<group>"; };
+		3260468377DA9DB4DEE9AF6D /* NotificationsConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigDataFlow.swift; sourceTree = "<group>"; };
+		36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeDataFlow.swift; sourceTree = "<group>"; };
 		36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorStateModel.swift; sourceTree = "<group>"; };
 		3811DE0725C9D32E00A708ED /* BaseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; };
 		3811DE0825C9D32F00A708ED /* BaseProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseProvider.swift; sourceTree = "<group>"; };
@@ -495,6 +508,7 @@
 		38E44533274E411700EC9A94 /* Disk+Errors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Disk+Errors.swift"; sourceTree = "<group>"; };
 		38E873FD274F761800975559 /* CoreNFC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreNFC.framework; path = System/Library/Frameworks/CoreNFC.framework; sourceTree = SDKROOT; };
 		38E87402274F78C000975559 /* libswiftCoreNFC.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreNFC.tbd; path = usr/lib/swift/libswiftCoreNFC.tbd; sourceTree = SDKROOT; };
+		38E87407274F9AD000975559 /* UserNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationsManager.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@@ -564,6 +578,7 @@
 		AAFF91130F2FCCC7EBBA11AD /* BasalProfileEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorStateModel.swift; sourceTree = "<group>"; };
 		AEE53A13D26F101B332EFFC8 /* AddTempTargetProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddTempTargetProvider.swift; sourceTree = "<group>"; };
 		AF65DA88F972B56090AD6AC3 /* PumpConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigDataFlow.swift; sourceTree = "<group>"; };
+		B5822B15939E719628E9FF7C /* SnoozeRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeRootView.swift; sourceTree = "<group>"; };
 		B5EF98E22A39CD656A230704 /* AutotuneConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutotuneConfigProvider.swift; sourceTree = "<group>"; };
 		B8C7F882606FF83A21BE00D8 /* PumpSettingsEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpSettingsEditorRootView.swift; sourceTree = "<group>"; };
 		B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMDataFlow.swift; sourceTree = "<group>"; };
@@ -578,6 +593,7 @@
 		D295A3F870E826BE371C0BB5 /* AutotuneConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutotuneConfigStateModel.swift; sourceTree = "<group>"; };
 		D97F14812C1AFED3621165A5 /* PumpSettingsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpSettingsEditorProvider.swift; sourceTree = "<group>"; };
 		DA241FB1663EC96FDBE64C8A /* CalibrationsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CalibrationsDataFlow.swift; sourceTree = "<group>"; };
+		DC2C6489D29ECCCAD78E0721 /* NotificationsConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigStateModel.swift; sourceTree = "<group>"; };
 		E00EEBFD27368630002FF094 /* ServiceAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFE27368630002FF094 /* SecurityAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecurityAssembly.swift; sourceTree = "<group>"; };
 		E00EEBFF27368630002FF094 /* StorageAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageAssembly.swift; sourceTree = "<group>"; };
@@ -585,7 +601,9 @@
 		E00EEC0127368630002FF094 /* APSAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APSAssembly.swift; sourceTree = "<group>"; };
 		E00EEC0227368630002FF094 /* NetworkAssembly.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = "<group>"; };
 		E013D871273AC6FE0014109C /* GlucoseSimulatorSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSimulatorSource.swift; sourceTree = "<group>"; };
+		E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeStateModel.swift; sourceTree = "<group>"; };
 		E2EBA7C03C26FCC67E16D798 /* LibreConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigProvider.swift; sourceTree = "<group>"; };
+		E625985B47742D498CB1681A /* NotificationsConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigProvider.swift; sourceTree = "<group>"; };
 		E68CDC1E5C438D1BEAD4CF24 /* LibreConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigStateModel.swift; sourceTree = "<group>"; };
 		E9AAB83FB6C3B41EFD1846A0 /* AddTempTargetRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddTempTargetRootView.swift; sourceTree = "<group>"; };
 		FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorRootView.swift; sourceTree = "<group>"; };
@@ -640,6 +658,14 @@
 			path = ConfigEditor;
 			sourceTree = "<group>";
 		};
+		0A67A70F9438DB6586398458 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				B5822B15939E719628E9FF7C /* SnoozeRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		0D76BBC81CEDC1A0050F45EF /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -680,6 +706,17 @@
 			path = Main;
 			sourceTree = "<group>";
 		};
+		29B478DF61BF8D270F7D8954 /* Snooze */ = {
+			isa = PBXGroup;
+			children = (
+				36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */,
+				1CAE81192B118804DCD23034 /* SnoozeProvider.swift */,
+				E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */,
+				0A67A70F9438DB6586398458 /* View */,
+			);
+			path = Snooze;
+			sourceTree = "<group>";
+		};
 		34CA4DF169B53D67EF18ED8A /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -708,10 +745,12 @@
 				3811DE1A25C9D48300A708ED /* Main */,
 				5031FE61F63C2A8A8B7674DD /* ManualTempBasal */,
 				D533BF261CDC1C3F871E7BFD /* NightscoutConfig */,
+				F66B236E00924A05D6A9F9DF /* NotificationsConfig */,
 				3E1C41D9301B7058AA7BF5EA /* PreferencesEditor */,
 				99C01B871ACAB3F32CE755C7 /* PumpConfig */,
 				E493126EA71765130F64CCE5 /* PumpSettingsEditor */,
 				3811DE3825C9D4A100A708ED /* Settings */,
+				29B478DF61BF8D270F7D8954 /* Snooze */,
 				6517011F19F244F64E1FF14B /* TargetsEditor */,
 			);
 			path = Modules;
@@ -817,6 +856,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				38E87406274F9AA500975559 /* UserNotifiactions */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				38B4F3C425E5016800E76A18 /* Notifications */,
@@ -1193,6 +1233,14 @@
 			path = Disk;
 			sourceTree = "<group>";
 		};
+		38E87406274F9AA500975559 /* UserNotifiactions */ = {
+			isa = PBXGroup;
+			children = (
+				38E87407274F9AD000975559 /* UserNotificationsManager.swift */,
+			);
+			path = UserNotifiactions;
+			sourceTree = "<group>";
+		};
 		38E98A1A25F52C9300C0CED0 /* Logger */ = {
 			isa = PBXGroup;
 			children = (
@@ -1535,6 +1583,25 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		F5DE2E6D7B2133BBD3353DC7 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
+		F66B236E00924A05D6A9F9DF /* NotificationsConfig */ = {
+			isa = PBXGroup;
+			children = (
+				3260468377DA9DB4DEE9AF6D /* NotificationsConfigDataFlow.swift */,
+				E625985B47742D498CB1681A /* NotificationsConfigProvider.swift */,
+				DC2C6489D29ECCCAD78E0721 /* NotificationsConfigStateModel.swift */,
+				F5DE2E6D7B2133BBD3353DC7 /* View */,
+			);
+			path = NotificationsConfig;
+			sourceTree = "<group>";
+		};
 		F75CB57ED6971B46F8756083 /* CGM */ = {
 			isa = PBXGroup;
 			children = (
@@ -1796,6 +1863,7 @@
 				3871F39C25ED892B0013ECB5 /* TempTarget.swift in Sources */,
 				3811DEAB25C9D88300A708ED /* HTTPResponseStatus.swift in Sources */,
 				3811DE5F25C9D4D500A708ED /* ProgressBar.swift in Sources */,
+				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
 				38BF021D25E7E3AF00579895 /* Reservoir.swift in Sources */,
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3862CC05273D152B00BF832C /* CalibrationService.swift in Sources */,
@@ -1918,6 +1986,14 @@
 				320D030F724170A637F06D50 /* CalibrationsProvider.swift in Sources */,
 				E25073BC86C11C3D6A42F5AC /* CalibrationsStateModel.swift in Sources */,
 				BA90041DC8991147E5C8C3AA /* CalibrationsRootView.swift in Sources */,
+				E3A08AAE59538BC8A8ABE477 /* NotificationsConfigDataFlow.swift in Sources */,
+				0F7A65FBD2CD8D6477ED4539 /* NotificationsConfigProvider.swift in Sources */,
+				3171D2818C7C72CD1584BB5E /* NotificationsConfigStateModel.swift in Sources */,
+				CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */,
+				6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */,
+				6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */,
+				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,
+				0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 8 - 1
FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -10,5 +10,12 @@
     "insulinReqFraction": 0.7,
     "skipBolusScreenAfterCarbs": false,
     "cgm": "nightscout",
-    "uploadGlucose": false
+    "uploadGlucose": false,
+    "glucoseBadge": false,
+    "glucoseNotificationsAlways": false,
+    "useAlarmSound": false,
+    "addSourceInfoToGlucoseNotifications": false,
+    "lowGlucose": 72,
+    "highGlucose": 270,
+    "carbsRequiredThreshold": 10
 }

+ 4 - 0
FreeAPS/Sources/APS/CGM/AppGroupSource.swift

@@ -60,6 +60,10 @@ struct AppGroupSource: GlucoseSource {
         let epoch = Double((timestamp as NSString).substring(with: matchRange))! / 1000
         return Date(timeIntervalSince1970: epoch)
     }
+
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Group ID: \(String(describing: Bundle.main.appGroupSuiteName))"]
+    }
 }
 
 public extension Bundle {

+ 4 - 0
FreeAPS/Sources/APS/CGM/DexcomSource.swift

@@ -61,6 +61,10 @@ extension DexcomSource: TransmitterManagerDelegate {
         }
         promise?(.success(bloodGlucose))
     }
+
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Dexcom tramsmitter ID: \(transmitterID)"]
+    }
 }
 
 extension BloodGlucose.Direction {

+ 4 - 0
FreeAPS/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -177,4 +177,8 @@ class IntelligentGenerator: BloodGlucoseGenerator {
             generateNewTrend()
         }
     }
+
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
+    }
 }

+ 9 - 1
FreeAPS/Sources/APS/CGM/GlucoseSource.swift

@@ -1,5 +1,13 @@
 import Combine
 
-protocol GlucoseSource {
+protocol SourceInfoProvider {
+    func sourceInfo() -> [String: Any]?
+}
+
+protocol GlucoseSource: SourceInfoProvider {
     func fetch() -> AnyPublisher<[BloodGlucose], Never>
 }
+
+extension GlucoseSource {
+    func sourceInfo() -> [String: Any]? { nil }
+}

+ 7 - 0
FreeAPS/Sources/APS/CGM/LibreTransmitterSource.swift

@@ -41,6 +41,13 @@ final class BaseLibreTransmitterSource: LibreTransmitterSource, Injectable {
         .replaceError(with: [])
         .eraseToAnyPublisher()
     }
+
+    func sourceInfo() -> [String: Any]? {
+        if let battery = manager?.battery {
+            return ["transmitterBattery": battery]
+        }
+        return nil
+    }
 }
 
 extension BaseLibreTransmitterSource: LibreTransmitterManagerDelegate {

+ 5 - 1
FreeAPS/Sources/APS/FetchGlucoseManager.swift

@@ -3,7 +3,7 @@ import Foundation
 import SwiftDate
 import Swinject
 
-protocol FetchGlucoseManager {}
+protocol FetchGlucoseManager: SourceInfoProvider {}
 
 final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue")
@@ -88,6 +88,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             }
             .store(in: &lifetime)
     }
+
+    func sourceInfo() -> [String: Any]? {
+        glucoseSource.sourceInfo()
+    }
 }
 
 extension UserDefaults {

+ 31 - 0
FreeAPS/Sources/APS/Storage/GlucoseStorage.swift

@@ -11,12 +11,14 @@ protocol GlucoseStorage {
     func isGlucoseFresh() -> Bool
     func isGlucoseNotFlat() -> Bool
     func nightscoutGlucoseNotUploaded() -> [BloodGlucose]
+    var alarm: GlucoseAlarm? { get }
 }
 
 final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseStorage.processQueue")
     @Injected() private var storage: FileStorage!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var settingsManager: SettingsManager!
 
     private enum Config {
         static let filterTime: TimeInterval = 4.5 * 60
@@ -100,8 +102,37 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         return Array(Set(recentGlucose).subtracting(Set(uploaded)))
     }
+
+    var alarm: GlucoseAlarm? {
+        guard let glucose = recent().last, glucose.dateString.addingTimeInterval(20.minutes.timeInterval) > Date(),
+              let glucoseValue = glucose.glucose else { return nil }
+
+        if Decimal(glucoseValue) < settingsManager.settings.lowGlucose {
+            return .low
+        }
+
+        if Decimal(glucoseValue) > settingsManager.settings.highGlucose {
+            return .high
+        }
+
+        return nil
+    }
 }
 
 protocol GlucoseObserver {
     func glucoseDidUpdate(_ glucose: [BloodGlucose])
 }
+
+enum GlucoseAlarm {
+    case high
+    case low
+
+    var displayName: String {
+        switch self {
+        case .high:
+            return NSLocalizedString("LOWALERT!", comment: "LOWALERT!")
+        case .low:
+            return NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!")
+        }
+    }
+}

+ 1 - 26
FreeAPS/Sources/Application/AppDelegate.swift

@@ -1,29 +1,4 @@
 import SwiftUI
 import UIKit
-import UserNotifications
 
-class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject {
-    @Published var notificationAction: NotificationAction? = nil
-
-    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
-        UNUserNotificationCenter.current().delegate = self
-        return true
-    }
-}
-
-extension AppDelegate: UNUserNotificationCenterDelegate {
-    func userNotificationCenter(
-        _: UNUserNotificationCenter,
-        didReceive response: UNNotificationResponse,
-        withCompletionHandler completionHandler: @escaping () -> Void
-    ) {
-        if let action = response.notification.request.content.userInfo["action"] as? String {
-            notificationAction = NotificationAction(rawValue: action)
-        }
-        completionHandler()
-    }
-}
-
-enum NotificationAction: String {
-    case snoozeAlert = "snooze"
-}
+class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject {}

+ 2 - 1
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -35,6 +35,8 @@ import Swinject
         _ = resolver.resolve(FetchGlucoseManager.self)!
         _ = resolver.resolve(FetchTreatmentsManager.self)!
         _ = resolver.resolve(FetchAnnouncementsManager.self)!
+        _ = resolver.resolve(CalendarManager.self)!
+        _ = resolver.resolve(UserNotificationsManager.self)!
     }
 
     init() {
@@ -44,7 +46,6 @@ import Swinject
     var body: some Scene {
         WindowGroup {
             Main.RootView(resolver: resolver)
-                .environmentObject(appDelegate)
         }
         .onChange(of: scenePhase) { newScenePhase in
             debug(.default, "APPLICATION PHASE: \(newScenePhase)")

+ 2 - 1
FreeAPS/Sources/Assemblies/ServiceAssembly.swift

@@ -13,6 +13,7 @@ final class ServiceAssembly: Assembly {
             reporter.setup()
             return reporter
         }
-        container.register(CalendarManager.self) { r in BaseCalendarManager(resilver: r) }
+        container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
+        container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
     }
 }

+ 38 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -14,6 +14,13 @@ struct FreeAPSSettings: JSON, Equatable {
     var cgm: CGMType = .nightscout
     var uploadGlucose: Bool = false
     var useCalendar: Bool = false
+    var glucoseBadge: Bool = false
+    var glucoseNotificationsAlways: Bool = false
+    var useAlarmSound: Bool = false
+    var addSourceInfoToGlucoseNotifications: Bool = false
+    var lowGlucose: Decimal = 72
+    var highGlucose: Decimal = 270
+    var carbsRequiredThreshold: Decimal = 10
 }
 
 extension FreeAPSSettings: Decodable {
@@ -74,6 +81,37 @@ extension FreeAPSSettings: Decodable {
             settings.useCalendar = useCalendar
         }
 
+        if let glucoseBadge = try? container.decode(Bool.self, forKey: .glucoseBadge) {
+            settings.glucoseBadge = glucoseBadge
+        }
+
+        if let glucoseNotificationsAlways = try? container.decode(Bool.self, forKey: .glucoseNotificationsAlways) {
+            settings.glucoseNotificationsAlways = glucoseNotificationsAlways
+        }
+
+        if let useAlarmSound = try? container.decode(Bool.self, forKey: .useAlarmSound) {
+            settings.useAlarmSound = useAlarmSound
+        }
+
+        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) {
+            settings.carbsRequiredThreshold = carbsRequiredThreshold
+        }
+
         self = settings
     }
 }

+ 2 - 1
FreeAPS/Sources/Modules/Base/BaseStateModel.swift

@@ -45,11 +45,12 @@ class BaseStateModel<Provider>: StateModel, Injectable where Provider: FreeAPS.P
 
     func subscribeSetting<T: Equatable, U: Publisher>(
         _ keyPath: WritableKeyPath<FreeAPSSettings, T>,
-        on settingPublisher: U, initial: (T) -> Void, didSet: ((T) -> Void)? = nil
+        on settingPublisher: U, initial: (T) -> Void, map: ((T) -> (T))? = nil, didSet: ((T) -> Void)? = nil
     ) where U.Output == T, U.Failure == Never {
         initial(settingsManager.settings[keyPath: keyPath])
         settingPublisher
             .removeDuplicates()
+            .map(map ?? { $0 })
             .sink { [weak self] value in
                 self?.settingsManager.settings[keyPath: keyPath] = value
                 didSet?(value)

+ 4 - 2
FreeAPS/Sources/Modules/Home/HomeStateModel.swift

@@ -8,7 +8,6 @@ extension Home {
         @Injected() var broadcaster: Broadcaster!
         @Injected() var apsManager: APSManager!
         @Injected() var nightscoutManager: NightscoutManager!
-        @Injected() var calendarManager: CalendarManager!
         private let timer = DispatchTimer(timeInterval: 5)
         private(set) var filteredHours = 24
 
@@ -46,6 +45,7 @@ extension Home {
         @Published var allowManualTemp = false
         @Published var units: GlucoseUnits = .mmolL
         @Published var pumpDisplayState: PumpDisplayState?
+        @Published var alarm: GlucoseAlarm?
 
         override func subscribe() {
             setupGlucose()
@@ -66,6 +66,7 @@ extension Home {
             closedLoop = settingsManager.settings.closedLoop
             lastLoopDate = apsManager.lastLoopDate
             carbsRequired = suggestion?.carbsReq
+            alarm = provider.glucoseStorage.alarm
 
             setStatusTitle()
             setupCurrentTempTarget()
@@ -178,7 +179,7 @@ extension Home {
                 } else {
                     self.glucoseDelta = nil
                 }
-                self.calendarManager.createEvent(for: self.recentGlucose, delta: self.glucoseDelta)
+                self.alarm = self.provider.glucoseStorage.alarm
             }
         }
 
@@ -344,6 +345,7 @@ extension Home.StateModel:
         allowManualTemp = !settings.closedLoop
         closedLoop = settingsManager.settings.closedLoop
         units = settingsManager.settings.units
+        setupGlucose()
     }
 
     func pumpHistoryDidUpdate(_: [PumpHistoryEvent]) {

+ 2 - 0
FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -4,6 +4,7 @@ struct CurrentGlucoseView: View {
     @Binding var recentGlucose: BloodGlucose?
     @Binding var delta: Int?
     @Binding var units: GlucoseUnits
+    @Binding var alarm: GlucoseAlarm?
 
     private var glucoseFormatter: NumberFormatter {
         let formatter = NumberFormatter()
@@ -43,6 +44,7 @@ struct CurrentGlucoseView: View {
                 )
                 .font(.system(size: 24, weight: .bold))
                 .fixedSize()
+                .foregroundColor(alarm == nil ? .primary : .loopRed)
                 image.padding(.bottom, 2)
 
             }.padding(.leading, 4)

+ 17 - 2
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -55,11 +55,26 @@ extension Home {
                 CurrentGlucoseView(
                     recentGlucose: $state.recentGlucose,
                     delta: $state.glucoseDelta,
-                    units: $state.units
+                    units: $state.units,
+                    alarm: $state.alarm
                 )
                 .onTapGesture {
-                    state.openCGM()
+                    if state.alarm == nil {
+                        state.openCGM()
+                    } else {
+                        state.showModal(for: .snooze)
+                    }
+                }
+                .onLongPressGesture {
+                    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                    impactHeavy.impactOccurred()
+                    if state.alarm == nil {
+                        state.showModal(for: .snooze)
+                    } else {
+                        state.openCGM()
+                    }
                 }
+
                 Spacer()
                 PumpView(
                     reservoir: $state.reservoir,

+ 0 - 5
FreeAPS/Sources/Modules/LibreConfig/View/LibreConfigRootView.swift

@@ -6,14 +6,12 @@ extension LibreConfig {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
-        @EnvironmentObject var appDelegate: AppDelegate
 
         var body: some View {
             Group {
                 if state.configured, let manager = state.source.manager {
                     LibreTransmitterSettingsView(
                         manager: manager,
-                        openSnooze: appDelegate.notificationAction == .snoozeAlert,
                         glucoseUnit: state.unit
                     ) {
                         self.state.source.manager = nil
@@ -21,9 +19,6 @@ extension LibreConfig {
                     } completion: {
                         state.hideModal()
                     }
-                    .onAppear {
-                        appDelegate.notificationAction = nil
-                    }
                 } else {
                     LibreTransmitterSetupView { manager in
                         self.state.source.manager = manager

+ 0 - 8
FreeAPS/Sources/Modules/Main/View/MainRootView.swift

@@ -5,7 +5,6 @@ extension Main {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
-        @EnvironmentObject var appDelegate: AppDelegate
 
         var body: some View {
             router.view(for: .home)
@@ -31,13 +30,6 @@ extension Main {
                     )
                 }
                 .onAppear(perform: configureView)
-                .onReceive(appDelegate.$notificationAction) { action in
-                    switch action {
-                    case .snoozeAlert:
-                        state.showModal(for: .libreConfig)
-                    default: break
-                    }
-                }
         }
     }
 }

+ 5 - 0
FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigDataFlow.swift

@@ -0,0 +1,5 @@
+enum NotificationsConfig {
+    enum Config {}
+}
+
+protocol NotificationsConfigProvider {}

+ 3 - 0
FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigProvider.swift

@@ -0,0 +1,3 @@
+extension NotificationsConfig {
+    final class Provider: BaseProvider, NotificationsConfigProvider {}
+}

+ 46 - 0
FreeAPS/Sources/Modules/NotificationsConfig/NotificationsConfigStateModel.swift

@@ -0,0 +1,46 @@
+import SwiftUI
+
+extension NotificationsConfig {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var glucoseBadge = false
+        @Published var glucoseNotificationsAlways = false
+        @Published var useAlarmSound = false
+        @Published var addSourceInfoToGlucoseNotifications = false
+        @Published var lowGlucose: Decimal = 0
+        @Published var highGlucose: Decimal = 0
+        @Published var carbsRequiredThreshold: Decimal = 0
+        var units: GlucoseUnits = .mmolL
+
+        override func subscribe() {
+            let units = settingsManager.settings.units
+            self.units = units
+
+            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
+            subscribeSetting(\.glucoseNotificationsAlways, on: $glucoseNotificationsAlways) { glucoseNotificationsAlways = $0 }
+            subscribeSetting(\.useAlarmSound, on: $useAlarmSound) { useAlarmSound = $0 }
+            subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {
+                addSourceInfoToGlucoseNotifications = $0 }
+
+            subscribeSetting(\.lowGlucose, on: $lowGlucose, initial: {
+                let value = max(min($0, 400), 40)
+                lowGlucose = units == .mmolL ? value.asMmolL : value
+            }, map: {
+                guard units == .mmolL else { return $0 }
+                return $0.asMgdL
+            })
+
+            subscribeSetting(\.highGlucose, on: $highGlucose, initial: {
+                let value = max(min($0, 400), 40)
+                highGlucose = units == .mmolL ? value.asMmolL : value
+            }, map: {
+                guard units == .mmolL else { return $0 }
+                return $0.asMgdL
+            })
+
+            subscribeSetting(
+                \.carbsRequiredThreshold,
+                on: $carbsRequiredThreshold
+            ) { carbsRequiredThreshold = $0 }
+        }
+    }
+}

+ 64 - 0
FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift

@@ -0,0 +1,64 @@
+import SwiftUI
+import Swinject
+
+extension NotificationsConfig {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+
+        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
+        }
+
+        var body: some View {
+            Form {
+                Section(header: Text("Glucose")) {
+                    Toggle("Show glucose on the app badge", isOn: $state.glucoseBadge)
+                    Toggle("Always Notify Glucose", isOn: $state.glucoseNotificationsAlways)
+                    Toggle("Also play alert sound", isOn: $state.useAlarmSound)
+                    Toggle("Also add source info", isOn: $state.addSourceInfoToGlucoseNotifications)
+
+                    HStack {
+                        Text("Low")
+                        Spacer()
+                        DecimalTextField("0", value: $state.lowGlucose, formatter: glucoseFormatter)
+                        Text(state.units.rawValue).foregroundColor(.secondary)
+                    }
+
+                    HStack {
+                        Text("High")
+                        Spacer()
+                        DecimalTextField("0", value: $state.highGlucose, formatter: glucoseFormatter)
+                        Text(state.units.rawValue).foregroundColor(.secondary)
+                    }
+                }
+
+                Section(header: Text("Other")) {
+                    HStack {
+                        Text("Carbs Requered Threshold")
+                        Spacer()
+                        DecimalTextField("0", value: $state.carbsRequiredThreshold, formatter: carbsFormatter)
+                        Text("г").foregroundColor(.secondary)
+                    }
+                }
+            }
+            .onAppear(perform: configureView)
+            .navigationBarTitle("Notifications")
+            .navigationBarTitleDisplayMode(.automatic)
+        }
+    }
+}

+ 1 - 0
FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -20,6 +20,7 @@ extension Settings {
                 Section(header: Text("Services")) {
                     Text("Nightscout").navigationLink(to: .nighscoutConfig, from: self)
                     Text("CGM").navigationLink(to: .cgm, from: self)
+                    Text("Notifications").navigationLink(to: .notificationsConfig, from: self)
                 }
 
                 Section(header: Text("Configuration")) {

+ 5 - 0
FreeAPS/Sources/Modules/Snooze/SnoozeDataFlow.swift

@@ -0,0 +1,5 @@
+enum Snooze {
+    enum Config {}
+}
+
+protocol SnoozeProvider {}

+ 3 - 0
FreeAPS/Sources/Modules/Snooze/SnoozeProvider.swift

@@ -0,0 +1,3 @@
+extension Snooze {
+    final class Provider: BaseProvider, SnoozeProvider {}
+}

+ 14 - 0
FreeAPS/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -0,0 +1,14 @@
+import SwiftUI
+
+extension Snooze {
+    final class StateModel: BaseStateModel<Provider> {
+        @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast
+        @Injected() var glucoseStogare: GlucoseStorage!
+
+        @Published var alarm: GlucoseAlarm?
+
+        override func subscribe() {
+            alarm = glucoseStogare.alarm
+        }
+    }
+}

+ 162 - 0
FreeAPS/Sources/Modules/Snooze/View/SnoozeRootView.swift

@@ -0,0 +1,162 @@
+import AudioToolbox
+import SwiftUI
+import Swinject
+
+extension Snooze {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+
+        @State private var selectedInterval = 0
+        @State private var snoozeDescription = "nothing to see here"
+
+        private var pickerTimes: [TimeInterval] {
+            var arr: [TimeInterval] = []
+
+            let mins10 = 0.166_67
+            let mins20 = mins10 * 2
+            let mins30 = mins10 * 3
+            // let mins40 = mins10 * 4
+
+            for hr in 0 ..< 2 {
+                for min in [0.0, mins20, mins20 * 2] {
+                    arr.append(TimeInterval(hours: Double(hr) + min))
+                }
+            }
+            for hr in 2 ..< 4 {
+                for min in [0.0, mins30] {
+                    arr.append(TimeInterval(hours: Double(hr) + min))
+                }
+            }
+
+            for hr in 4 ... 8 {
+                arr.append(TimeInterval(hours: Double(hr)))
+            }
+
+            return arr
+        }
+
+        private var formatter: DateComponentsFormatter {
+            let formatter = DateComponentsFormatter()
+            formatter.allowsFractionalUnits = false
+            formatter.unitsStyle = .full
+            return formatter
+        }
+
+        private var dateFormatter: DateFormatter {
+            let formatter = DateFormatter()
+            formatter.timeStyle = .short
+            return formatter
+        }
+
+        private func formatInterval(_ interval: TimeInterval) -> String {
+            formatter.string(from: interval)!
+        }
+
+        func getSnoozeDescription() -> String {
+            var snoozeDescription = ""
+            var celltext = ""
+
+            switch state.alarm {
+            case .high:
+                celltext = NSLocalizedString("High Glucose Alarm active", comment: "High Glucose Alarm active")
+            case .low:
+                celltext = NSLocalizedString("Low Glucose Alarm active", comment: "Low Glucose Alarm active")
+            case .none:
+                celltext = NSLocalizedString("No Glucose Alarm active", comment: "No Glucose Alarm active")
+            }
+
+            if state.snoozeUntilDate > Date() {
+                snoozeDescription = String(
+                    format: NSLocalizedString("snoozing until %@", comment: "snoozing until %@"),
+                    dateFormatter.string(from: state.snoozeUntilDate)
+                )
+            } else {
+                snoozeDescription = NSLocalizedString("not snoozing", comment: "not snoozing")
+            }
+
+            return [celltext, snoozeDescription].joined(separator: ", ")
+        }
+
+        var snoozeButton: some View {
+            VStack(alignment: .leading) {
+                Button {
+                    let interval = pickerTimes[selectedInterval]
+                    let snoozeFor = formatter.string(from: interval)!
+                    let untilDate = Date() + interval
+                    state.snoozeUntilDate = untilDate < Date() ? .distantPast : untilDate
+                    debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
+                    snoozeDescription = getSnoozeDescription()
+                    BaseUserNotificationsManager.stopSound()
+                } label: {
+                    Text("Click to Snooze Alerts")
+                        .padding()
+                }
+            }
+        }
+
+        private var snoozePicker: some View {
+            VStack {
+                Picker(selection: $selectedInterval, label: Text("Strength")) {
+                    ForEach(0 ..< pickerTimes.count) {
+                        Text(formatInterval(self.pickerTimes[$0]))
+                    }
+                }
+                .pickerStyle(.wheel)
+            }
+        }
+
+        var snoozeDesc: some View {
+            VStack(alignment: .leading) {
+                Text(snoozeDescription)
+            }
+        }
+
+        var body: some View {
+            Form {
+                snoozeDesc
+                snoozePicker
+                snoozeButton
+            }
+            .navigationBarTitle("Snooze Alerts")
+            .navigationBarTitleDisplayMode(.automatic)
+            .navigationBarItems(leading: Button("Close", action: state.hideModal))
+            .onAppear {
+                configureView()
+                snoozeDescription = getSnoozeDescription()
+            }
+        }
+    }
+}
+
+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(hours: hours)
+    }
+
+    init(minutes: Double) {
+        // self.init(minutes * 60)
+        let m = minutes * 60
+        self.init(m)
+    }
+
+    init(hours: Double) {
+        self.init(minutes: hours * 60)
+    }
+
+    var minutes: Double {
+        self / 60.0
+    }
+
+    var hours: Double {
+        minutes / 60.0
+    }
+}

+ 6 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -23,6 +23,8 @@ enum Screen: Identifiable, Hashable {
     case cgm
     case libreConfig
     case calibrations
+    case notificationsConfig
+    case snooze
 
     var id: Int { String(reflecting: self).hashValue }
 }
@@ -72,6 +74,10 @@ extension Screen {
             LibreConfig.RootView(resolver: resolver)
         case .calibrations:
             Calibrations.RootView(resolver: resolver)
+        case .notificationsConfig:
+            NotificationsConfig.RootView(resolver: resolver)
+        case .snooze:
+            Snooze.RootView(resolver: resolver)
         }
     }
 

+ 25 - 2
FreeAPS/Sources/Services/Calendar/CalendarManager.swift

@@ -14,9 +14,13 @@ final class BaseCalendarManager: CalendarManager, Injectable {
 
     @Persisted(key: "CalendarManager.currentCalendarID") var currentCalendarID: String? = nil
     @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var glucoseStorage: GlucoseStorage!
 
-    init(resilver: Resolver) {
-        injectServices(resilver)
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        broadcaster.register(GlucoseObserver.self, observer: self)
+        setupGlucose()
     }
 
     func requestAccessIfNeeded() -> AnyPublisher<Bool, Never> {
@@ -117,6 +121,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             formatter.minimumFractionDigits = 1
             formatter.maximumFractionDigits = 1
         }
+        formatter.roundingMode = .halfUp
         return formatter
     }
 
@@ -127,6 +132,24 @@ final class BaseCalendarManager: CalendarManager, Injectable {
         formatter.positivePrefix = "+"
         return formatter
     }
+
+    func setupGlucose() {
+        let glucose = glucoseStorage.recent()
+        let recentGlucose = glucose.last
+        let glucoseDelta: Int?
+        if glucose.count >= 2 {
+            glucoseDelta = (recentGlucose?.glucose ?? 0) - (glucose[glucose.count - 2].glucose ?? 0)
+        } else {
+            glucoseDelta = nil
+        }
+        createEvent(for: recentGlucose, delta: glucoseDelta)
+    }
+}
+
+extension BaseCalendarManager: GlucoseObserver {
+    func glucoseDidUpdate(_: [BloodGlucose]) {
+        setupGlucose()
+    }
 }
 
 extension BloodGlucose.Direction {

+ 15 - 0
FreeAPS/Sources/Services/Network/NightscoutManager.swift

@@ -27,6 +27,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     @Injected() private var reachabilityManager: ReachabilityManager!
 
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
+    private var ping: TimeInterval?
 
     private var lifetime = Lifetime()
 
@@ -66,6 +67,13 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
+    func sourceInfo() -> [String: Any]? {
+        if let ping = ping {
+            return [GlucoseSourceKey.nightscoutPing.rawValue: ping]
+        }
+        return nil
+    }
+
     var cgmURL: URL? {
         if let url = settingsManager.settings.cgm.appURL {
             return url
@@ -82,6 +90,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
 
     func fetchGlucose() -> AnyPublisher<[BloodGlucose], Never> {
         let useLocal = settingsManager.settings.useLocalGlucoseSource
+        ping = nil
 
         if !useLocal {
             guard isNetworkReachable else {
@@ -97,6 +106,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             return Just([]).eraseToAnyPublisher()
         }
 
+        let startDate = Date()
+
         let since = glucoseStorage.syncDate()
         return nightscout.fetchLastGlucose(sinceDate: since)
             .tryCatch({ (error) -> AnyPublisher<[BloodGlucose], Error> in
@@ -104,6 +115,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 return Just([]).setFailureType(to: Error.self).eraseToAnyPublisher()
             })
             .replaceError(with: [])
+            .handleEvents(receiveOutput: { value in
+                guard value.isNotEmpty else { return }
+                self.ping = Date().timeIntervalSince(startDate)
+            })
             .eraseToAnyPublisher()
     }
 

+ 1 - 1
FreeAPS/Sources/Services/SettingsManager/SettingsManager.swift

@@ -14,7 +14,7 @@ final class BaseSettingsManager: SettingsManager, Injectable {
     @Injected() var broadcaster: Broadcaster!
     @Injected() var storage: FileStorage!
 
-    var settings: FreeAPSSettings {
+    @SyncAccess var settings: FreeAPSSettings {
         didSet {
             if oldValue != settings {
                 save()

+ 373 - 0
FreeAPS/Sources/Services/UserNotifiactions/UserNotificationsManager.swift

@@ -0,0 +1,373 @@
+import AudioToolbox
+import Foundation
+import Swinject
+import UIKit
+import UserNotifications
+
+protocol UserNotificationsManager {}
+
+enum GlucoseSourceKey: String {
+    case transmitterBattery
+    case nightscoutPing
+    case description
+}
+
+final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
+    private enum Identifier: String {
+        case glucocoseNotification = "FreeAPS.glucoseNotification"
+        case carbsRequiredNotification = "FreeAPS.carbsRequiredNotification"
+        case noLoopFirstNotification = "FreeAPS.noLoopFirstNotification"
+        case noLoopSecondNotification = "FreeAPS.noLoopSecondNotification"
+    }
+
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var apsManager: APSManager!
+    @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
+
+    @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
+
+    private let center = UNUserNotificationCenter.current()
+    private var lifetime = Lifetime()
+
+    init(resolver: Resolver) {
+        super.init()
+        center.delegate = self
+        injectServices(resolver)
+        broadcaster.register(GlucoseObserver.self, observer: self)
+        broadcaster.register(SuggestionObserver.self, observer: self)
+
+        requestNotificationPermissionsIfNeeded()
+        sendGlucoseNotification()
+        subscribeOnLoop()
+    }
+
+    private func subscribeOnLoop() {
+        apsManager.lastLoopDateSubject
+            .sink { [weak self] date in
+                self?.scheduleMissingLoopNotifiactions(date: date)
+            }
+            .store(in: &lifetime)
+    }
+
+    private func addAppBadge(glucose: Int?) {
+        guard let glucose = glucose, settingsManager.settings.glucoseBadge else {
+            DispatchQueue.main.async {
+                UIApplication.shared.applicationIconBadgeNumber = 0
+            }
+            return
+        }
+
+        let badge: Int
+        if settingsManager.settings.units == .mmolL {
+            badge = Int(round(Double((glucose * 10).asMmolL)))
+        } else {
+            badge = glucose
+        }
+
+        DispatchQueue.main.async {
+            UIApplication.shared.applicationIconBadgeNumber = badge
+        }
+    }
+
+    private func notifyCarbsRequired(_ carbs: Int) {
+        guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold else { return }
+
+        ensureCanSendNotification {
+            var titles: [String] = []
+
+            let content = UNMutableNotificationContent()
+
+            if self.snoozeUntilDate > Date() {
+                titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
+            } else {
+                content.sound = .default
+                self.playSound()
+            }
+
+            titles.append(String(format: NSLocalizedString("Carbs required: %d g", comment: "Carbs required"), carbs))
+
+            content.title = titles.joined(separator: " ")
+            content.body = String(
+                format: NSLocalizedString(
+                    "To prevent LOW required %d g of carbs",
+                    comment: "To prevent LOW required %d g of carbs"
+                ),
+                carbs
+            )
+
+            self.addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true)
+        }
+    }
+
+    private func scheduleMissingLoopNotifiactions(date _: Date) {
+        ensureCanSendNotification {
+            let title = NSLocalizedString("FreeAPS X not active", comment: "FreeAPS X not active")
+            let body = NSLocalizedString("Last loop was more then %d min ago", comment: "Last loop was more then %d min ago")
+
+            let firstInterval = 20 // min
+            let secondInterval = 40 // min
+
+            let firstContent = UNMutableNotificationContent()
+            firstContent.title = title
+            firstContent.body = String(format: body, firstInterval)
+            firstContent.sound = .default
+
+            let secondContent = UNMutableNotificationContent()
+            secondContent.title = title
+            secondContent.body = String(format: body, secondInterval)
+            secondContent.sound = .default
+
+            let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
+            let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
+
+            self.addRequest(
+                identifier: .noLoopFirstNotification,
+                content: firstContent,
+                deleteOld: true,
+                trigger: firstTrigger
+            )
+            self.addRequest(
+                identifier: .noLoopSecondNotification,
+                content: secondContent,
+                deleteOld: true,
+                trigger: secondTrigger
+            )
+        }
+    }
+
+    private func sendGlucoseNotification() {
+        addAppBadge(glucose: nil)
+
+        let glucose = glucoseStorage.recent()
+
+        guard let lastGlucose = glucose.last, let glucoseValue = lastGlucose.glucose else { return }
+
+        addAppBadge(glucose: lastGlucose.glucose)
+
+        guard glucoseStorage.alarm != nil || settingsManager.settings.glucoseNotificationsAlways else {
+            return
+        }
+
+        ensureCanSendNotification {
+            var titles: [String] = []
+
+            var notificationAlarm = false
+
+            switch self.glucoseStorage.alarm {
+            case .none:
+                titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
+            case .low:
+                titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
+                notificationAlarm = true
+                self.playSoundIfNeeded()
+            case .high:
+                titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
+                notificationAlarm = true
+                self.playSoundIfNeeded()
+            }
+
+            if self.snoozeUntilDate > Date() {
+                titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
+                notificationAlarm = false
+            }
+
+            let delta = glucose.count >= 2 ? glucoseValue - (glucose[glucose.count - 2].glucose ?? 0) : nil
+
+            let body = self.glucoseText(glucoseValue: glucoseValue, delta: delta, direction: lastGlucose.direction) + self
+                .infoBody()
+
+            titles.append(body)
+
+            let content = UNMutableNotificationContent()
+            content.title = titles.joined(separator: " ")
+            content.body = body
+
+            if notificationAlarm {
+                content.sound = .default
+            }
+
+            self.addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true)
+        }
+    }
+
+    private func glucoseText(glucoseValue: Int, delta: Int?, direction: BloodGlucose.Direction?) -> String {
+        let units = settingsManager.settings.units
+        let glucoseText = glucoseFormatter
+            .string(from: Double(
+                units == .mmolL ? glucoseValue
+                    .asMmolL : Decimal(glucoseValue)
+            ) as NSNumber)! + " " + NSLocalizedString(units.rawValue, comment: "units")
+        let directionText = direction?.symbol ?? "↔︎"
+        let deltaText = delta
+            .map {
+                self.deltaFormatter
+                    .string(from: Double(
+                        units == .mmolL ? $0
+                            .asMmolL : Decimal($0)
+                    ) as NSNumber)!
+            } ?? "--"
+
+        return glucoseText + " " + directionText + " " + deltaText
+    }
+
+    private func infoBody() -> String {
+        var body = ""
+
+        if settingsManager.settings.addSourceInfoToGlucoseNotifications,
+           let info = sourceInfoProvider.sourceInfo()
+        {
+            // Description
+            if let description = info[GlucoseSourceKey.description.rawValue] as? String {
+                body.append("\n" + description)
+            }
+
+            // NS ping
+            if let ping = info[GlucoseSourceKey.nightscoutPing.rawValue] as? TimeInterval {
+                body.append(
+                    "\n"
+                        + String(
+                            format: NSLocalizedString("Nightscout ping: %d ms", comment: "Nightscout ping"),
+                            Int(ping * 1000)
+                        )
+                )
+            }
+
+            // Transmitter battery
+            if let transmitterBattery = info[GlucoseSourceKey.transmitterBattery.rawValue] as? Int {
+                body.append(
+                    "\n"
+                        + String(
+                            format: NSLocalizedString("Transmitter: %@%%", comment: "Transmitter: %@%%"),
+                            transmitterBattery
+                        )
+                )
+            }
+        }
+        return body
+    }
+
+    private func requestNotificationPermissionsIfNeeded() {
+        center.getNotificationSettings { settings in
+            debug(.service, "UNUserNotificationCenter.authorizationStatus: \(String(describing: settings.authorizationStatus))")
+            if ![.authorized, .provisional].contains(settings.authorizationStatus) {
+                self.requestNotificationPermissions()
+            }
+        }
+    }
+
+    private func requestNotificationPermissions() {
+        debug(.service, "requestNotificationPermissions")
+        center.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in
+            if granted {
+                debug(.service, "requestNotificationPermissions was granted")
+            } else {
+                warning(.service, "requestNotificationPermissions failed", error: error)
+            }
+        }
+    }
+
+    private func ensureCanSendNotification(_ completion: @escaping () -> Void) {
+        center.getNotificationSettings { settings in
+            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
+                warning(.service, "ensureCanSendNotification failed, authorization denied")
+                return
+            }
+
+            debug(.service, "Sending notification was allowed")
+
+            completion()
+        }
+    }
+
+    private func addRequest(
+        identifier: Identifier,
+        content: UNMutableNotificationContent,
+        deleteOld: Bool = false,
+        trigger: UNNotificationTrigger? = nil
+    ) {
+        let request = UNNotificationRequest(identifier: identifier.rawValue, content: content, trigger: trigger)
+
+        if deleteOld {
+            center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue])
+            center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
+        }
+
+        center.add(request) { error in
+            if let error = error {
+                warning(.service, "Unable to addNotificationRequest", error: error)
+                return
+            }
+
+            debug(.service, "Sending \(identifier) notification")
+        }
+    }
+
+    private func playSoundIfNeeded() {
+        guard settingsManager.settings.useAlarmSound, snoozeUntilDate < Date() else { return }
+        Self.stopPlaying = false
+        playSound()
+    }
+
+    static let soundID: UInt32 = 1336
+    private static var stopPlaying = false
+
+    private func playSound(times: Int = 3) {
+        guard times > 0, !Self.stopPlaying else {
+            return
+        }
+
+        AudioServicesPlaySystemSoundWithCompletion(Self.soundID) {
+            self.playSound(times: times - 1)
+        }
+    }
+
+    static func stopSound() {
+        stopPlaying = true
+        AudioServicesDisposeSystemSoundID(soundID)
+    }
+
+    private var glucoseFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 0
+        if settingsManager.settings.units == .mmolL {
+            formatter.minimumFractionDigits = 1
+            formatter.maximumFractionDigits = 1
+        }
+        formatter.roundingMode = .halfUp
+        return formatter
+    }
+
+    private var deltaFormatter: NumberFormatter {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = 2
+        formatter.positivePrefix = "+"
+        return formatter
+    }
+}
+
+extension BaseUserNotificationsManager: GlucoseObserver {
+    func glucoseDidUpdate(_: [BloodGlucose]) {
+        sendGlucoseNotification()
+    }
+}
+
+extension BaseUserNotificationsManager: SuggestionObserver {
+    func suggestionDidUpdate(_ suggestion: Suggestion) {
+        guard let carndRequired = suggestion.carbsReq else { return }
+        notifyCarbsRequired(Int(carndRequired))
+    }
+}
+
+extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
+    func userNotificationCenter(
+        _: UNUserNotificationCenter,
+        willPresent _: UNNotification,
+        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+    ) {
+        completionHandler([.banner, .badge, .sound])
+    }
+}