فهرست منبع

add notification toggles for message subtypes; prettify SwiftMessages

kskandis 1 سال پیش
والد
کامیت
8f3d208425

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

@@ -33,6 +33,10 @@ struct FreeAPSSettings: JSON, Equatable {
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarEmojis: Bool = false
     var glucoseBadge: Bool = false
+    var notificationsPump: Bool = false
+    var notificationsCgm: Bool = false
+    var notificationsCarb: Bool = false
+    var notificationsAlgorithm: Bool = false
     var glucoseNotificationsAlways: Bool = false
     var useAlarmSound: Bool = false
     var addSourceInfoToGlucoseNotifications: Bool = false
@@ -203,6 +207,22 @@ extension FreeAPSSettings: Decodable {
             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 glucoseNotificationsAlways = try? container.decode(Bool.self, forKey: .glucoseNotificationsAlways) {
             settings.glucoseNotificationsAlways = glucoseNotificationsAlways
         }

+ 10 - 0
FreeAPS/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -9,12 +9,22 @@ extension GlucoseNotificationSettings {
         @Published var lowGlucose: Decimal = 0
         @Published var highGlucose: Decimal = 0
 
+        @Published var notificationsPump = false
+        @Published var notificationsCgm = false
+        @Published var notificationsCarb = false
+        @Published var notificationsAlgorithm = false
+
         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(\.glucoseNotificationsAlways, on: $glucoseNotificationsAlways) { glucoseNotificationsAlways = $0 }
             subscribeSetting(\.useAlarmSound, on: $useAlarmSound) { useAlarmSound = $0 }

+ 170 - 97
FreeAPS/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -55,26 +55,94 @@ extension GlucoseNotificationSettings {
 
         var body: some View {
             Form {
-                if !notificationsDisabled {
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.glucoseBadge,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0
-                                hintLabel = "Show Glucose App Badge"
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: "Show Glucose App Badge",
-                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                        headerText: "Various Glucose Notifications"
-                    )
-                }
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.notificationsPump,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Always Notify Pump"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Always Notify Pump",
+                    miniHint: "Always Notify Pump Warnings",
+                    verboseHint: "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.\n\nIf iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.\n\nAn example of a Pump Warning is 'Pod Expiration Reminder'",
+                    headerText: "Trio Information Notifications"
+                )
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.notificationsCgm,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Always Notify CGM"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Always Notify CGM",
+                    miniHint: "Always Notify CGM Warnings",
+                    verboseHint: "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.\n\nIf iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.\n\nAn 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
+                            hintLabel = "Always Notify Carb"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Always Notify Carb",
+                    miniHint: "Always Notify Carb Warnings",
+                    verboseHint: "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.\n\nIf iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.\n\nAn 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
+                            hintLabel = "Always Notify Algorithm"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Always Notify Algorithm",
+                    miniHint: "Always Notify Algorithm Warnings",
+                    verboseHint: "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.\n\nIf iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.\n\nAn example of a 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
+                            hintLabel = "Show Glucose App Badge"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Show Glucose App Badge",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    headerText: "Various Glucose Notifications"
+                )
 
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
@@ -84,7 +152,7 @@ extension GlucoseNotificationSettings {
                         get: { selectedVerboseHint },
                         set: {
                             selectedVerboseHint = $0
-                            hintLabel = "Always Notify Glucose"
+                            hintLabel = "e"
                         }
                     ),
                     units: state.units,
@@ -94,82 +162,81 @@ extension GlucoseNotificationSettings {
                     verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
                 )
 
-                if !notificationsDisabled {
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.useAlarmSound,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0
-                                hintLabel = "Play Alarm Sound"
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: "Play Alarm Sound",
-                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
-                    )
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.useAlarmSound,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Play Alarm Sound"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Play Alarm Sound",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                )
 
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.addSourceInfoToGlucoseNotifications,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0
-                                hintLabel = "Add Glucose Source to Alarm"
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: "Add Glucose Source to Alarm",
-                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
-                    )
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.addSourceInfoToGlucoseNotifications,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0
+                            hintLabel = "Add Glucose Source to Alarm"
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: "Add Glucose Source to Alarm",
+                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                )
 
-                    Section {
-                        HStack {
-                            Text("Low Glucose Alarm Limit")
-                            Spacer()
-                            TextFieldWithToolBar(text: $state.lowGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
-                            Text(state.units.rawValue).foregroundColor(.secondary)
-                        }.padding(.top)
+                Section {
+                    HStack {
+                        Text("Low Glucose Alarm Limit")
+                        Spacer()
+                        TextFieldWithToolBar(text: $state.lowGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
+                        Text(state.units.rawValue).foregroundColor(.secondary)
+                    }.padding(.top)
 
-                        HStack {
-                            Text("High Glucose Alarm Limit")
-                            Spacer()
-                            TextFieldWithToolBar(text: $state.highGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
-                            Text(state.units.rawValue).foregroundColor(.secondary)
-                        }
+                    HStack {
+                        Text("High Glucose Alarm Limit")
+                        Spacer()
+                        TextFieldWithToolBar(text: $state.highGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
+                        Text(state.units.rawValue).foregroundColor(.secondary)
+                    }
 
-                        HStack(alignment: .top) {
-                            Text(
-                                "Set the lower and upper limit for glucose alarms. See hint for more details."
-                            )
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                            Spacer()
-                            Button(
-                                action: {
-                                    hintLabel = "Low and High Glucose Alarm Limits"
-                                    selectedVerboseHint =
-                                        "These two settings limit the range outside of which you will be notified via push notifications. If your CGM readings are below 'Low' or above 'High', you will receive a glucose alarm."
-                                    shouldDisplayHint.toggle()
-                                },
-                                label: {
-                                    HStack {
-                                        Image(systemName: "questionmark.circle")
-                                    }
+                    HStack(alignment: .top) {
+                        Text(
+                            "Set the lower and upper limit for glucose alarms. See hint for more details."
+                        )
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                        .lineLimit(nil)
+                        Spacer()
+                        Button(
+                            action: {
+                                hintLabel = "Low and High Glucose Alarm Limits"
+                                selectedVerboseHint =
+                                    "These two settings limit the range outside of which you will be notified via push notifications. If your CGM readings are below 'Low' or above 'High', you will receive a glucose alarm."
+                                shouldDisplayHint.toggle()
+                            },
+                            label: {
+                                HStack {
+                                    Image(systemName: "questionmark.circle")
                                 }
-                            ).buttonStyle(BorderlessButtonStyle())
-                        }.padding(.vertical)
-                    }
-                    .listRowBackground(Color.chart)
+                            }
+                        ).buttonStyle(BorderlessButtonStyle())
+                    }.padding(.vertical)
+//                    }
+                        .listRowBackground(Color.chart)
                 }
             }
             .sheet(isPresented: $shouldDisplayHint) {
@@ -181,12 +248,18 @@ extension GlucoseNotificationSettings {
                     sheetTitle: "Help"
                 )
             }
-            .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
-                notificationsDisabled = $0
-            })
+            .onReceive(
+                resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
+                perform: {
+                    notificationsDisabled = $0
+                }
+            )
             .scrollContentBackground(.hidden).background(color)
-            .onAppear(perform: configureView)
-            .navigationBarTitle("Glucose Notifications")
+//            .onAppear(perform: configureView)
+            .onAppear {
+                configureView {}
+            }
+            .navigationBarTitle("Trio Notifications")
             .navigationBarTitleDisplayMode(.automatic)
         }
     }

+ 265 - 51
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -17,6 +17,7 @@ extension Home {
         @State private var statusTitle: String = ""
         @State var showPumpSelection: Bool = false
         @State var notificationsDisabled = false
+        @State var alertSafetyNotificationsViewHeight = 0
 
         struct Buttons: Identifiable {
             let label: String
@@ -57,6 +58,158 @@ extension Home {
 
         // TODO: end todo
 
+        func sendTestRepeat(storedMessages: [MessageContent], repeats: Bool = false) { // TODO: REMOVE!!!
+            if repeats == true {
+                for loop in 0 ... 5 {
+                    for i in 0 ... storedMessages.count - 1 {
+                        var count = 0
+                        _ = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { t in
+                            print(count)
+                            print(storedMessages[count].content)
+                            router.alertMessage.send(storedMessages[count])
+                            count += 1
+                            if count >= storedMessages.count {
+                                t.invalidate()
+                            }
+                        }
+                    }
+                }
+            } else {
+                for i in 0 ... storedMessages.count - 1 {
+                    print(i)
+                    print(storedMessages[i].content)
+                    router.alertMessage.send(storedMessages[i])
+                }
+            }
+        }
+
+        func sendTestTriggerMessage() { // TODO: REMOVE!!!
+            var storedMessages: [MessageContent] = []
+            var messageCont: MessageContent
+
+            let firstInterval = 1 // min
+            let secondInterval = 2 // min
+            let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
+            messageCont = MessageContent(
+                content: "Last Loop was more than 20 min ago - TEST",
+                type: MessageType.info,
+                subtype: .algorithm,
+                title: "Trio Not Active",
+                useAPN: false,
+                trigger: firstTrigger
+            )
+            debug(
+                .default,
+                "TEST \(messageCont.title) \(messageCont.content) \(messageCont.type) \(messageCont.subtype)"
+            )
+            storedMessages.append(messageCont)
+
+            let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
+            messageCont = MessageContent(
+                content: "Last Loop was more than 40 min ago - TEST",
+                type: MessageType.warning,
+                subtype: .algorithm,
+                title: "Trio Not Active",
+                useAPN: false,
+                trigger: secondTrigger
+            )
+            debug(
+                .default,
+                "TEST \(messageCont.title) \(messageCont.content) \(messageCont.type) \(messageCont.subtype)"
+            )
+            storedMessages.append(messageCont)
+
+            sendTestRepeat(storedMessages: storedMessages, repeats: true)
+        }
+
+        func sendTestNotifications() { // TODO: REMOVE!!!
+            var storedMessages: [MessageContent] = []
+            var messageCont: MessageContent
+
+            sendTestTriggerMessage()
+//            return
+            messageCont = MessageContent(
+                // content: "68 mg/dL" + "↔︎" + "-1" + "\n" + "Plugin CGM Source",
+                content: "68 mg/dL" + "↔︎" + "-1",
+                type: MessageType.warning,
+                subtype: .glucose,
+                title: "LOWALERT! 68 mg/dL" + "↔︎" + "-1",
+                useAPN: true
+            )
+            router.alertMessage.send(messageCont)
+
+            messageCont = MessageContent(
+                content: "Insulin delivery stopped. Change Pod now.",
+                type: MessageType.error, // errorPump
+                subtype: .pump,
+                title: "Critical Pod Fault 008",
+                action: .pumpConfig
+            )
+            storedMessages.append(messageCont)
+
+            messageCont = MessageContent(
+                content: "Pod expires in 68 hours.",
+                type: MessageType.warning,
+                subtype: .pump,
+                title: "Pod Expiration Reminder"
+            )
+            storedMessages.append(messageCont)
+            messageCont = MessageContent(
+                content: "10 U insulin or less remaining in Pod. Change Pod soon.",
+                type: MessageType.warning,
+                subtype: .pump,
+                title: "Low Reservoir"
+            )
+            storedMessages.append(messageCont)
+
+            messageCont = MessageContent(
+                content: "To prevent LOW required 30 g of carbs",
+                type: MessageType.warning,
+                subtype: .carb,
+                title: "Carbs required: 30 g"
+            )
+            storedMessages.append(messageCont)
+
+            messageCont = MessageContent(
+                content: "83 mg/dL" + "↔︎" + "-1" + "\n" + "Plugin CGM Source",
+                type: MessageType.other,
+                subtype: .glucose,
+                title: "Glucose 83 mg/dL" + "↔︎" + "-1"
+            )
+            storedMessages.append(messageCont)
+            messageCont = MessageContent(
+                content: "68 mg/dL" + "↔︎" + "-1" + "\n" + "Plugin CGM Source",
+                type: MessageType.warning,
+                subtype: .glucose,
+                title: "LOWALERT! 68 mg/dL" + "↔︎" + "-1"
+            )
+            storedMessages.append(messageCont)
+
+            messageCont = MessageContent(
+                content: "Error: Invalid glucose: Not enough glucose data",
+                type: MessageType.info,
+                subtype: .algorithm
+            )
+            storedMessages.append(messageCont)
+
+//            info(.apsManager, "Not enough glucose data")
+//            info(.apsManager, "Glucose data is stale")
+//            info(.apsManager, "Glucose data is too flat")
+//            info(.apsManager, "Glucose validation failed")
+//            info(.apsManager, "Loop not possible during the manual basal temp")
+//            info(.apsManager, "Temp Basal failed with error")
+//            info(.apsManager, "Pump not suspended by Announcement")
+
+//            sendTestRepeat(storedMessages: storedMessages, repeats: true)
+
+            sendTestRepeat(storedMessages: storedMessages, repeats: false)
+            for i in 0 ... storedMessages.count - 1 {
+                print(i)
+                print(storedMessages[i].content)
+                router.alertMessage.send(storedMessages[i])
+            }
+        }
+
         var bolusProgressFormatter: NumberFormatter {
             let formatter = NumberFormatter()
             formatter.numberStyle = .decimal
@@ -129,22 +282,6 @@ extension Home {
             }
         }
 
-        private func sendSafetyNotification() {
-            let messageCont = MessageContent(
-                content: NSLocalizedString(
-                    "Fix now by turning Notifications ON.",
-                    comment: "Secondary text for alerts disabled warning, which appears on the main status screen."
-                ),
-                type: MessageType.alertPermissionWarning,
-                title: NSLocalizedString(
-                    "⚠️ Safety Notifications are OFF", // \u{26A0}
-                    comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled"
-                ),
-                useAPN: false
-            )
-            router.alertMessage.send(messageCont)
-        }
-
         var glucoseView: some View {
             CurrentGlucoseView(
                 timerDate: $state.timerDate,
@@ -698,41 +835,122 @@ extension Home {
             }
         }
 
-        @ViewBuilder func mainView() -> some View {
+        @ViewBuilder func alertSafetyNotificationsView(geo: GeometryProxy) -> some View {
+            ZStack {
+                /// rectangle as background
+                RoundedRectangle(cornerRadius: 15)
+                    .fill(
+                        Color(
+                            red: 0.9,
+                            green: 0.133333333,
+                            blue: 0.2156862745
+                        )
+                    )
+                    .clipShape(RoundedRectangle(cornerRadius: 15))
+                    .frame(height: geo.size.height * 0.08)
+                    .coordinateSpace(name: "alertSafetyNotificationsView")
+                    .shadow(
+                        color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
+                            Color.black.opacity(0.33),
+                        radius: 3
+                    )
+                HStack {
+                    Spacer()
+                    VStack {
+                        Text("⚠️ Safety Notifications are OFF")
+                            .font(.subheadline)
+                            .font(.system(size: 15, weight: .bold, design: .rounded))
+                            .foregroundStyle(.white.gradient)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                        Text("Fix now by turning Notifications ON.")
+                            .font(.caption)
+                            .font(.system(size: 12, weight: .bold, design: .rounded))
+                            .foregroundStyle(.white.gradient)
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                    }.padding(.leading, 5)
+                    Spacer()
+                    Image(systemName: "chevron.right").foregroundColor(.white)
+                        .font(.system(size: 15, design: .rounded))
+                }.padding(.horizontal, 10)
+                    .padding(.trailing, 8)
+                    .onTapGesture {
+                        UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+                    }
+            }.padding(.horizontal, 10)
+                .padding(.top, 0)
+        }
+
+        @ViewBuilder func mainViewWithScrollView() -> some View {
             GeometryReader { geo in
-                VStack(spacing: 0) {
-                    ZStack {
-                        /// glucose bobble
-                        glucoseView
+                ScrollView(.vertical, showsIndicators: false) {
+                    mainViewViews(geo)
+                }
+            }
+        }
 
-                        /// right panel with loop status and evBG
-                        HStack {
-                            Spacer()
-                            rightHeaderPanel(geo)
-                        }.padding(.trailing, 20)
+        @ViewBuilder func mainViewViews(_ geo: GeometryProxy) -> some View {
+            VStack(spacing: 0) {
+                if notificationsDisabled {
+                    alertSafetyNotificationsView(geo: geo)
+                        .padding(.top, UIDevice.adjustPadding(min: nil, max: 40))
+                }
+                ZStack(alignment: .top) {
+                    /// glucose bobble
+                    glucoseView
 
-                        /// left panel with pump related info
-                        HStack {
-                            pumpView
-                            Spacer()
-                        }.padding(.leading, 20)
-                    }.padding(.top, 10)
+                    /// right panel with loop status and evBG
+                    HStack {
+                        Spacer()
+                        rightHeaderPanel(geo)
+                    }.padding(.trailing, 20)
 
-                    mealPanel(geo).padding(.top, UIDevice.adjustPadding(min: nil, max: 30))
-                        .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 20))
+                    /// left panel with pump related info
+                    HStack {
+                        pumpView
+                        Spacer()
+                    }.padding(.leading, 20)
+                }.padding(.top, 10)
 
-                    mainChart(geo: geo)
+                mealPanel(geo).padding(.top, UIDevice.adjustPadding(min: nil, max: 30))
+                    .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 20))
 
-                    timeInterval.padding(.top, UIDevice.adjustPadding(min: 0, max: 12))
-                        .padding(.bottom, UIDevice.adjustPadding(min: 0, max: 12))
+                mainChart(geo: geo)
 
-                    if let progress = state.bolusProgress {
-                        bolusView(geo: geo, progress).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
-                    } else {
-                        profileView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
+                timeInterval.padding(.top, UIDevice.adjustPadding(min: 0, max: 12))
+                    .padding(.bottom, UIDevice.adjustPadding(min: 0, max: 12))
+
+                if let progress = state.bolusProgress {
+                    bolusView(geo: geo, progress)
+                        .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
+                } else {
+                    profileView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40))
+                }
+            }
+            .background(color)
+            .onReceive(
+                resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
+                perform: { // AlertPermissionsChecker
+                    if notificationsDisabled != $0 {
+                        notificationsDisabled = $0
+                        if notificationsDisabled {
+                            debug(.default, "notificationsDisabled")
+                        }
+                    }
+                }
+            )
+        }
+
+        @ViewBuilder func mainView() -> some View {
+            GeometryReader { geo in
+                if notificationsDisabled {
+                    ScrollView(.vertical, showsIndicators: false) {
+                        mainViewViews(geo)
+                    }
+                } else {
+                    GeometryReader { geo in
+                        mainViewViews(geo)
                     }
                 }
-                .background(color)
             }
             .onChange(of: state.hours) { _ in
                 highlightButtons()
@@ -742,14 +960,6 @@ extension Home {
                     highlightButtons()
                 }
             }
-            .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
-                if notificationsDisabled != $0 {
-                    notificationsDisabled = $0
-                    if notificationsDisabled {
-                        sendSafetyNotification()
-                    }
-                }
-            })
             .navigationTitle("Home")
             .navigationBarHidden(true)
             .ignoresSafeArea(.keyboard)
@@ -764,6 +974,7 @@ extension Home {
                     )
                     .onTapGesture {
                         state.isStatusPopupPresented = false
+                        sendTestNotifications() // TODO: Remove!
                     }
                     .gesture(
                         DragGesture(minimumDistance: 10, coordinateSpace: .local)
@@ -1028,7 +1239,10 @@ extension UIDevice {
         case largeDevice = 852 // Height for 6.1" iPhone 15 Pro
     }
 
-    @usableFromInline static func adjustPadding(min: CGFloat? = nil, max: CGFloat? = nil) -> CGFloat? {
+    @usableFromInline static func adjustPadding(
+        min: CGFloat? = nil,
+        max: CGFloat? = nil
+    ) -> CGFloat? {
         if UIScreen.screenHeight > UIDevice.DeviceSize.smallDevice.rawValue {
             if UIScreen.screenHeight >= UIDevice.DeviceSize.largeDevice.rawValue {
                 return max

+ 247 - 144
FreeAPS/Sources/Modules/Main/MainStateModel.swift

@@ -13,86 +13,160 @@ extension Main {
         @Published var isSecondaryModalPresented = false
         @Published var secondaryModalView: AnyView? = nil
 
-        private var storedMessages: [MessageContent] = []
-        private let maxStoredMessages = 3
-        private let maxNotificationsPerMinute = 3
-        private var lastMessageTimestamp: Date?
-        private var timer: AnyCancellable?
-        private var timeInterval: TimeInterval = 1
-        private let limitInterval: TimeInterval = 20
-        private var lastNotificationTime: TimeInterval = 0
-        private var sentNotifications: [TimeInterval] = []
-
-        // Method to queue new message and check if it matches the "NOTE-*" pattern
-        func queueMessageIfNeeded(_ message: MessageContent) {
-            if message.type != MessageType.info {
-                showAlertMessage(message)
-                return
+        @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
+        private var timers: [TimeInterval: Timer] = [:]
+
+        private var formatter: DateComponentsFormatter { // TODO: Remove debug only
+            let formatter = DateComponentsFormatter()
+            formatter.allowsFractionalUnits = false
+            formatter.unitsStyle = .full
+            return formatter
+        }
+
+        private var dateFormatter: DateFormatter { // TODO: Remove debug only
+            let formatter = DateFormatter()
+            formatter.timeStyle = .short
+            return formatter
+        }
+
+        private func formatInterval(_ interval: TimeInterval) -> String { // TODO: Remove debug only
+            formatter.string(from: interval)!
+        }
+
+        private func showTriggeredView(
+            message: MessageContent,
+            interval: TimeInterval,
+            config: SwiftMessages.Config,
+            view: MessageView
+        ) {
+            let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
+            let untilDate = Date() + interval
+            debug(
+                .default,
+                "Notification triggered for: \n \(String(describing: view.titleLabel?.text)) \(String(describing: view.bodyLabel?.text)) snoozed for \(snoozeFor) until \(dateFormatter.string(from: untilDate))"
+            )
+
+            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
+
+//            let interval = message.content.contains("20") ? TimeInterval(60) :
+//                TimeInterval(120) // TimeInterval(60) // trigger.timeInterval // TODO: remove 60 secs for test
+            let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
+            let untilDate = Date() + interval
+            debug(
+                .default,
+                "\(message.title) \(message.content) \(message.type) \(message.subtype) will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate)) for view.id \(view.id)"
+            )
+
+            SwiftMessages.hide(id: view.id)
+
+            // If a timer already exists for this interval, invalidate it
+            if let existingTimer = timers[interval] {
+                existingTimer.invalidate()
             }
-            if !storedMessages.filter({ $0.content == message.content && $0.title == message.title }).isEmpty { return }
 
-            storedMessages.append(message)
-            lastMessageTimestamp = Date()
+            // 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
+            }
 
-            // If we have accumulated messages, concatenate and display
-            if storedMessages.count >= maxStoredMessages {
-                checkAndDisplayStoredMessages()
-            } else {
-                startTimer()
+            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)
             }
         }
 
-        // Start or restart the timer that checks for the 1-minute interval
-        private func startTimer() {
-            timer = Timer.publish(every: timeInterval, on: .main, in: .common)
-                .autoconnect()
-                .sink { [weak self] _ in
-                    self?.checkAndDisplayStoredMessages()
-                }
+        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 // .foregroundColor
         }
 
-        // Method to check the stored messages and show them after 1 minute
-        private func checkAndDisplayStoredMessages() {
-            guard !storedMessages.isEmpty else { return }
-
-            // Ensure rate limit is not exceeded
-            let currentTime = Date().timeIntervalSince1970
-            pruneOldNotifications(currentTime: currentTime)
-
-            // Ensure we do not exceed maxNotificationsPerMinute
-            if sentNotifications.count < maxNotificationsPerMinute {
-                // If below the limit, send the next notification in the queue
-                if !alertPermissionsChecker.notificationsDisabled {
-                    let request = storedMessages.removeFirst()
-                    showAlertMessage(request)
-                    sentNotifications.append(currentTime)
-                } else {
-                    let max = storedMessages.count >= maxStoredMessages ? maxStoredMessages : storedMessages.count
-                    var content = ""
-                    for _ in 1 ... max {
-                        let request = storedMessages.removeFirst()
-                        sentNotifications.append(currentTime)
-                        content = content + request.content + "\n"
-                    }
-                    if content != "" {
-                        let messageCont = MessageContent(
-                            content: content,
-                            type: MessageType.other
-                        )
-                        showAlertMessage(messageCont)
-                    }
+        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()
                 }
             }
         }
 
-        // Remove notifications from the sent list that are older than `limitInterval`
-        private func pruneOldNotifications(currentTime: TimeInterval) {
-            // Remove any notifications older than `limitInterval`
-            sentNotifications = sentNotifications.filter { currentTime - $0 < limitInterval }
+        private func isApnPumpConfigAction(_ message: MessageContent) -> Bool {
+            if message.type != .error, message.action == .pumpConfig {
+                setupPumpConfig()
+                return true
+            }
+            return false
+        }
+
+        private func allowNotify(_ message: MessageContent) -> Bool {
+            if message.type == .error { return true } // .errorPump
+            switch message.subtype {
+            case .pump:
+                guard settingsManager.settings.notificationsPump else { return false }
+            case .cgm:
+                guard settingsManager.settings.notificationsCgm else { return false }
+            case .carb:
+                guard settingsManager.settings.notificationsCarb else { return false }
+            case .glucose:
+                guard settingsManager.settings.glucoseNotificationsAlways else { return false }
+            case .algorithm:
+                guard settingsManager.settings.notificationsAlgorithm else { return false }
+            case .misc:
+                return true
+            }
+            return true
         }
 
         private func showAlertMessage(_ message: MessageContent) {
-            if message.useAPN, !alertPermissionsChecker.notificationsDisabled, message.type != MessageType.pumpConfig {
+            if message.useAPN, !alertPermissionsChecker.notificationsDisabled
+            {
                 showAPN(message)
             } else {
                 showSwiftMessage(message)
@@ -100,35 +174,40 @@ extension Main {
         }
 
         private func showAPN(_ message: MessageContent) {
-            let messageCont = MessageContent(content: message.content, type: message.type)
-            switch message.type {
-            case .pumpConfig:
-                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)
-                }
-            default:
-                DispatchQueue.main.async {
-                    self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
-                        $0.alertMessageNotification(messageCont)
-                    }
+            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) {
             // SwiftMessages.pauseBetweenMessages = 1.0
+
+            if snoozeUntilDate > Date(), message.action == .snooze {
+                return
+            }
+
             var config = SwiftMessages.defaultConfig
             let view = MessageView.viewFromNib(layout: .cardView)
 
+//            viewRespectsSystemMinimumLayoutMargins = false
+            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: NSLocalizedString(message.content, comment: "Info message"),
@@ -139,81 +218,42 @@ extension Main {
                 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:
-                view.backgroundColor = .secondarySystemGroupedBackground
-                config.duration = .automatic
+                config.duration = .seconds(seconds: 5)//.automatic
                 titleContent = message.title != "" ? message.title : NSLocalizedString("Info", comment: "Info title")
             case .warning:
-                view.configureTheme(.warning, iconStyle: .subtle)
                 config.duration = .forever
-                view.button?.setImage(Icon.warningSubtle.image, for: .normal)
                 titleContent = message.title != "" ? message
                     .title : NSLocalizedString("Warning", comment: "Warning title")
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                }
-            case .errorPump:
-                view.configureTheme(.error, iconStyle: .subtle)
+            case .error:
                 config.duration = .forever
-                view.button?.setImage(Icon.errorSubtle.image, for: .normal)
                 titleContent = message.title != "" ? message
                     .title : NSLocalizedString("Error", comment: "Error title")
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                    // display the pump configuration immediatly
-                    if let pump = self.provider.deviceManager.pumpManager,
-                       let bluetooth = self.provider.bluetoothProvider
-                    {
-                        let view = PumpConfig.PumpSettingsView(
-                            pumpManager: pump,
-                            bluetoothManager: bluetooth,
-                            completionDelegate: self
-                        ).asAny()
-                        self.router.mainSecondaryModalView.send(view)
-                    }
-                }
-            case .alertPermissionWarning:
-                view.configureTheme(.error, iconStyle: .none)
-                config.duration = .forever
+            }
 
-                view.iconLabel = nil
-                view.iconImageView = nil
-                let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white)
-                view.button?.setImage(disclosureIndicator, for: .normal)
-                view.button?.backgroundColor = UIColor.red
-                view.button?.tintColor = UIColor.white
+            view.titleLabel?.text = titleContent
+            config.dimMode = .gray(interactive: true)
 
-                titleContent = message.title != "" ? message
-                    .title : NSLocalizedString("Error", comment: "Error title")
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
-                }
-            case .pumpConfig:
-                titleContent = ""
-                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)
-                }
+            setupAction(message: message, view: view)
+            if message.trigger != nil {
+                addOrReplaceTriggerTimer(message: message, config: config, view: view)
             }
 
-            if message.type != .pumpConfig
-            {
-                view.titleLabel?.text = titleContent
-                config.dimMode = .gray(interactive: true)
-                // Show if not hidden
-                if !view.isHidden {
-                    SwiftMessages.show(config: config, view: view)
-                }
-            }
+            guard message.type == .error || message.action != .pumpConfig, message.trigger == nil, !view.isHidden else { return }
+
+            SwiftMessages.show(config: config, view: view)
         }
 
         override func subscribe() {
@@ -237,7 +277,10 @@ extension Main {
             router.alertMessage
                 .receive(on: DispatchQueue.main)
                 .sink { message in
-                    self.queueMessageIfNeeded(message)
+                    guard !self.isApnPumpConfigAction(message) else { return }
+                    guard self.allowNotify(message) else { return }
+//                    self.queueMessageIfNeeded(message) // TODO: Remove if Batched Info and Throttled APNs are NOT in-scope
+                    self.showAlertMessage(message) // TODO: Call this if Batched Info and Throttled APNs are NOT in-scope
                 }
                 .store(in: &lifetime)
 
@@ -260,6 +303,57 @@ extension Main {
     }
 }
 
+extension MessageView {
+    func currentColorScheme() -> ColorScheme {
+        let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
+        return userInterfaceStyle == .dark ? .dark : .light
+    }
+
+    func customConfigureTheme(colorSchemePreference: ColorSchemeOption) {
+        let defaultSystemColorScheme = currentColorScheme() // UIColor.defaultSystemBackgroundColor
+        var backgroundColor = UIColor.systemBackground
+        var foregroundColor = UIColor.white
+        // Color.gray.opacity(0.1) is used by MainRootView but is translucent
+        // lightGray is same color as MainRootView background and systemGray6 is a shade darker
+        // systemGray5 is a shade darker than systemGray6 and gray is a few shades darker
+        // systemBackground is the same as systemGray6 when iOS is light mode
+        switch colorSchemePreference {
+        case .systemDefault:
+            backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemBackground :
+                UIColor(Color.black.opacity(0.9))
+            foregroundColor = UIColor.label
+        case .dark:
+            backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemGray5 :
+                UIColor(Color.black.opacity(0.9))
+            foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
+        case .light:
+            backgroundColor = defaultSystemColorScheme == .light ? .systemGray6 : 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) {
@@ -267,3 +361,12 @@ extension Main.StateModel: CompletionDelegate {
         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)) // size: 12
+    }
+}

+ 7 - 2
FreeAPS/Sources/Modules/Settings/SettingItems.swift

@@ -200,10 +200,15 @@ enum SettingItems {
     ]
 
     static let notificationItems = [
+        SettingItem(title: "Manage iOS Preferences", view: .notificationSettings),
         SettingItem(
-            title: "Glucose Notifications",
+            title: "Trio Notifications",
             view: .glucoseNotificationSettings,
             searchContents: [
+                "Always Notify Pump",
+                "Always Notify CGM",
+                "Always Notify Carb",
+                "Always Notify Algorithm",
                 "Show Glucose App Badge",
                 "Always Notify Glucose",
                 "Play Alarm Sound",
@@ -211,7 +216,7 @@ enum SettingItems {
                 "Low Glucose Alarm Limit",
                 "High Glucose Alarm Limit"
             ],
-            path: ["Notifications", "Glucose Notifications"]
+            path: ["Notifications", "Trio Notifications"] // Glucose
         ),
         SettingItem(
             title: "Live Activity",

+ 79 - 15
FreeAPS/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -14,8 +14,13 @@ struct NotificationsView: BaseView {
 
     @ObservedObject var state: Settings.StateModel
     @State var notificationsDisabled = false
+    @State var showAlert = false
+    @State private var shouldDisplayHint: Bool = false
+    @State var hintDetent = PresentationDetent.large
+    @State var selectedVerboseHint: String? =
+        "Notifications give you important Trio information without requiring you to open the app.\n\nKeep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications."
+    @State var hintLabel: String? = "Manage iOS Preferences"
 
-    @Environment(\.appName) private var appName
     @Environment(\.colorScheme) var colorScheme
     var color: LinearGradient {
         colorScheme == .dark ? LinearGradient(
@@ -37,18 +42,46 @@ struct NotificationsView: BaseView {
     var body: some View {
         Form {
             Section(
+                header: Text("Manage iOS Preferences"),
+                content: {
+                    manageNotifications
+                }
+            )
+            Section {
+                VStack {
+                    notificationsEnabledStatus
+                    HStack(alignment: .top) {
+                        Text(
+                            "Notifications give you important Trio information without requiring you to open the app."
+                        )
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                        .lineLimit(nil)
+                        Spacer()
+                        Button(
+                            action: {
+                                hintLabel = "Manage iOS Preferences"
+                                selectedVerboseHint =
+                                    "Notifications give you important Trio information without requiring you to open the app.\n\nKeep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications."
+                                shouldDisplayHint.toggle()
+                            },
+                            label: {
+                                HStack {
+                                    Image(systemName: "questionmark.circle")
+                                }
+                            }
+                        ).buttonStyle(BorderlessButtonStyle())
+                    }.padding(.top)
+                }.padding(.bottom)
+            }.listRowBackground(Color.chart)
+            Section(
                 header: Text("Notification Center"),
                 content: {
-                    Section(footer: DescriptiveText(label: String(format: NSLocalizedString("""
-                    Notifications give you important %1$@ app information without requiring you to open the app.
-
-                    Keep these turned ON in your phone’s settings to ensure you receive %1$@ Notifications, Critical Alerts, and Time Sensitive Notifications.
-                    """, comment: "Alert Permissions descriptive text (1: app name)"), appName)))
-                        {
-                            manageNotifications
-                            notificationsEnabledStatus
-                        }
-                    Text("Glucose Notifications").navigationLink(to: .glucoseNotificationSettings, from: self)
+//                    if !notificationsDisabled {
+                    // Text("Glucose Notifications").navigationLink(to: .glucoseNotificationSettings, from: self)//TODO: add MessageType settings
+                    Text("Trio Notifications")
+                        .navigationLink(to: .glucoseNotificationSettings, from: self) // Glucose Notifications
+//                    }
 
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)
@@ -59,9 +92,30 @@ struct NotificationsView: BaseView {
             )
             .listRowBackground(Color.chart)
         }
-        .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
-            notificationsDisabled = $0
-        })
+        .onReceive(
+            resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled,
+            perform: {
+                if notificationsDisabled != $0 {
+                    notificationsDisabled = $0
+                    if notificationsDisabled {
+                        showAlert = true
+                    }
+                }
+            }
+        )
+        .alert(
+            isPresented: self.$showAlert,
+            content: { self.notificationReminder() }
+        )
+        .sheet(isPresented: $shouldDisplayHint) {
+            SettingInputHintView(
+                hintDetent: $hintDetent,
+                shouldDisplayHint: $shouldDisplayHint,
+                hintLabel: hintLabel ?? "",
+                hintText: selectedVerboseHint ?? "",
+                sheetTitle: "Help"
+            )
+        }
         .scrollContentBackground(.hidden).background(color)
         .navigationTitle("Notifications")
         .navigationBarTitleDisplayMode(.automatic)
@@ -69,6 +123,16 @@ struct NotificationsView: BaseView {
 }
 
 extension NotificationsView {
+    func notificationReminder() -> Alert {
+        Alert(
+            title: Text("\u{2757} Notifications are Required"),
+            message: Text(
+                "Please authorize notifications by tapping 'Open iOS Settings' > 'Notifications' and enable 'Allow Notifications' for 'Notification Center' and 'Banners' Alerts."
+            ),
+            dismissButton: .default(Text("Ok"))
+        )
+    }
+
     @ViewBuilder private func onOff(_ val: Bool) -> some View {
         if val {
             Text(NSLocalizedString("On", comment: "Notification Setting Status is On"))
@@ -83,7 +147,7 @@ extension NotificationsView {
     private var manageNotifications: some View {
         Button(action: { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) }) {
             HStack {
-                Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text"))
+                Text(NSLocalizedString("Open iOS Settings", comment: "Manage Permissions in Settings button text"))
                 Spacer()
                 Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote)
             }

+ 14 - 5
FreeAPS/Sources/Router/Router.swift

@@ -2,20 +2,30 @@ import Combine
 import SwiftUI
 import Swinject
 
-enum MessageType {
+enum MessageType: String {
     case info
     case warning
-    case errorPump
-    case pumpConfig
-    case alertPermissionWarning
+    case error
     case other
 }
 
+enum MessageSubtype: String {
+    case pump
+    case cgm
+    case carb
+    case glucose
+    case algorithm
+    case misc
+}
+
 struct MessageContent {
     var content: String
     var type: MessageType = .info
+    var subtype: MessageSubtype = .misc
     var title: String = ""
     var useAPN: Bool = true
+    var trigger: UNNotificationTrigger? = nil
+    var action: NotificationAction = .none
 }
 
 protocol Router {
@@ -29,7 +39,6 @@ final class BaseRouter: Router {
     let mainModalScreen = CurrentValueSubject<Screen?, Never>(nil)
     let mainSecondaryModalView = CurrentValueSubject<AnyView?, Never>(nil)
     let alertMessage = PassthroughSubject<MessageContent, Never>()
-
     private let resolver: Resolver
 
     init(resolver: Resolver) {

+ 6 - 6
FreeAPS/Sources/Services/Notifications/AlertPermissionsChecker.swift

@@ -4,17 +4,17 @@ import LoopKit
 import SwiftUI
 import Swinject
 
-protocol AlertPermissionsCheckerDelegate: AnyObject {
-    func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool)
-}
+// protocol AlertPermissionsCheckerDelegate: AnyObject {
+//    func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool)
+// }
 
 public class AlertPermissionsChecker: ObservableObject, Injectable {
-    @Environment(\.appName) private var appName
+//    @Environment(\.appName) private var appName
     private lazy var cancellables = Set<AnyCancellable>()
     private var listeningToNotificationCenter = false
 
-    @Injected() private var apsManager: APSManager!
-    @Injected() private var router: Router!
+//    @Injected() private var apsManager: APSManager!
+//    @Injected() private var router: Router!
     @Published var notificationsDisabled: Bool = false
 
     init(resolver: Resolver) {

+ 69 - 41
FreeAPS/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -21,6 +21,7 @@ enum NotificationAction: String {
 
     case snooze
     case pumpConfig
+    case none
 }
 
 protocol BolusFailureObserver {
@@ -166,7 +167,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             ),
             carbs
         )
-        addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true)
+        addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true, messageSubtype: .carb)
     }
 
     private func scheduleMissingLoopNotifiactions(date _: Date) {
@@ -193,13 +194,17 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             identifier: .noLoopFirstNotification,
             content: firstContent,
             deleteOld: true,
-            trigger: firstTrigger
+            trigger: firstTrigger,
+            messageType: .error,
+            messageSubtype: .algorithm
         )
         addRequest(
             identifier: .noLoopSecondNotification,
             content: secondContent,
             deleteOld: true,
-            trigger: secondTrigger
+            trigger: secondTrigger,
+            messageType: .error,
+            messageSubtype: .algorithm
         )
     }
 
@@ -218,7 +223,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             identifier: .noLoopFirstNotification,
             content: content,
             deleteOld: true,
-            trigger: nil
+            trigger: nil,
+            messageType: .error,
+            messageSubtype: .pump
         )
     }
 
@@ -257,15 +264,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
 
             var titles: [String] = []
             var notificationAlarm = false
+            var messageType = MessageType.info
 
             switch glucoseStorage.alarm {
             case .none:
                 titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
             case .low:
                 titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
+                messageType = MessageType.warning
                 notificationAlarm = true
             case .high:
                 titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
+                messageType = MessageType.warning
                 notificationAlarm = true
             }
 
@@ -291,7 +301,14 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
                 }
 
-                addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true)
+                addRequest(
+                    identifier: .glucocoseNotification,
+                    content: content,
+                    deleteOld: true,
+                    messageType: messageType,
+                    messageSubtype: .glucose,
+                    action: NotificationAction.snooze
+                )
             }
         } catch {
             debugPrint(
@@ -376,44 +393,29 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    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,
-        messageType: MessageType = MessageType.other
+        messageType: MessageType = MessageType.other,
+        messageSubtype: MessageSubtype = MessageSubtype.misc,
+        action: NotificationAction = NotificationAction.none
     ) {
-        if alertPermissionsChecker.notificationsDisabled, trigger == nil {
-            if trigger != nil {
-                debug(.default, "TODO: Triggers are not supported by alertMessage")
-                return
-            }
+        if alertPermissionsChecker.notificationsDisabled {
             let messageCont = MessageContent(
                 content: content.body,
                 type: messageType,
+                subtype: messageSubtype,
                 title: content.title,
-                useAPN: false
+                useAPN: false,
+                trigger: trigger,
+                action: action
             )
             router.alertMessage.send(messageCont)
             return
         }
-        let timestamp = Date().timeIntervalSince1970
-        let uniqueIdentifier = "\(identifier.rawValue)_\(timestamp)"
-        content.threadIdentifier = String(describing: messageType)
-        let request = UNNotificationRequest(identifier: uniqueIdentifier, content: content, trigger: trigger)
+        let request = UNNotificationRequest(identifier: identifier.rawValue, content: content, trigger: trigger)
 
         if deleteOld {
             DispatchQueue.main.async {
@@ -483,16 +485,27 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent) {
         let content = UNMutableNotificationContent()
 
-        switch message.type {
-        case .info:
-            content.title = NSLocalizedString("Info", comment: "Info title")
-        case .warning:
-            content.title = NSLocalizedString("Warning", comment: "Warning title")
-        case .errorPump:
-            content.title = NSLocalizedString("Error", comment: "Error title")
-        default:
+        if message.title == "" {
+            switch message.type {
+            case .info:
+                content.title = NSLocalizedString("Info", comment: "Info title")
+            case .warning:
+                content.title = NSLocalizedString("Warning", comment: "Warning title")
+            case .error: // .errorPump:
+                content.title = NSLocalizedString("Error", comment: "Error title")
+            default:
+                content.title = message.title
+            }
+        } else {
             content.title = message.title
         }
+        switch message.action {
+        case .snooze:
+            content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
+        case .pumpConfig:
+            content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
+        default: break
+        }
 
         content.body = NSLocalizedString(message.content, comment: "Info message")
         content.sound = .default
@@ -500,8 +513,10 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
             identifier: .alertMessageNotification,
             content: content,
             deleteOld: true,
-            trigger: nil,
-            messageType: message.type
+            trigger: message.trigger,
+            messageType: message.type,
+            messageSubtype: message.subtype,
+            action: message.action
         )
     }
 }
@@ -510,8 +525,12 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
     func pumpNotification(alert: AlertEntry) {
         let content = UNMutableNotificationContent()
         let alertUp = alert.alertIdentifier.uppercased()
+        let typeMessage: MessageType
         if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
             content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
+            typeMessage = .error
+        } else {
+            typeMessage = .warning
         }
         content.title = alert.contentTitle ?? "Unknown"
         content.body = alert.contentBody ?? "Unknown"
@@ -521,7 +540,9 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
             content: content,
             deleteOld: true,
             trigger: nil,
-            messageType: MessageType.errorPump
+            messageType: typeMessage,
+            messageSubtype: .pump,
+            action: .pumpConfig
         )
     }
 
@@ -575,8 +596,15 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         case .snooze:
             router.mainModalScreen.send(.snooze)
         case .pumpConfig:
-            let messageCont = MessageContent(content: response.notification.request.content.body, type: MessageType.pumpConfig)
+            let messageCont = MessageContent(
+                content: response.notification.request.content.body,
+                type: MessageType.other,
+                subtype: .pump,
+                useAPN: false,
+                action: .pumpConfig
+            )
             router.alertMessage.send(messageCont)
+        default: break
         }
     }
 }