diff --git a/Common/Settings/GlucoseSchedules.swift b/Common/Settings/GlucoseSchedules.swift index 528d31a..6cd30d1 100644 --- a/Common/Settings/GlucoseSchedules.swift +++ b/Common/Settings/GlucoseSchedules.swift @@ -138,6 +138,19 @@ class GlucoseScheduleList: Codable, CustomStringConvertible { } return .none } + + public func shouldOverrideDoNotDisturb(_ currentGlucoseInMGDL: Double) -> Bool { + for schedule in activeSchedules { + let isAlarmingLow = (schedule.lowAlarm != nil && currentGlucoseInMGDL <= schedule.lowAlarm!) + let isAlarmingHigh = (schedule.highAlarm != nil && currentGlucoseInMGDL >= schedule.highAlarm!) + + if (isAlarmingLow || isAlarmingHigh) && (schedule.overrideDoNotDisturb == true) { + return true + } + } + + return false + } } class GlucoseSchedule: Codable, CustomStringConvertible { @@ -145,6 +158,7 @@ class GlucoseSchedule: Codable, CustomStringConvertible { var to: DateComponents? var lowAlarm: Double? var highAlarm: Double? + var overrideDoNotDisturb: Bool? var enabled: Bool? // glucose schedules are stored as standalone datecomponents (i.e. offsets) @@ -218,6 +232,6 @@ class GlucoseSchedule: Codable, CustomStringConvertible { } var description: String { - "(from: \(String(describing: from)), to: \(String(describing: to)), low: \(String(describing: lowAlarm)), high: \(String(describing: highAlarm)), enabled: \(String(describing: enabled)))" + "(from: \(String(describing: from)), to: \(String(describing: to)), low: \(String(describing: lowAlarm)), high: \(String(describing: highAlarm)), overrideDoNotDisturb: \(String(describing: overrideDoNotDisturb)), enabled: \(String(describing: enabled)))" } } diff --git a/LibreTransmitter.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index 7be9de6..3354331 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 2746C73F26DCF83700E31BD9 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2746C73D26DCF83400E31BD9 /* Features.swift */; }; 2746C74226DD0F8800E31BD9 /* Libre2DirectSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2746C74126DD0F8800E31BD9 /* Libre2DirectSetup.swift */; }; 274E71D3297ED77300FCFECD /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 274E71D2297ED77300FCFECD /* AuthView.swift */; }; - 274E71D52986D4A600FCFECD /* CriticalAlarmsVolumeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */; }; 275786AB26753CC400845D0E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275786AA26753CC400845D0E /* SettingsView.swift */; }; 275EC993265AEE970043210E /* NumericTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275EC992265AEE970043210E /* NumericTextField.swift */; }; 275EC998265AF64E0043210E /* StatusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275EC997265AF64E0043210E /* StatusMessage.swift */; }; @@ -213,7 +212,6 @@ 2746C74426DF636900E31BD9 /* SensorPairingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorPairingService.swift; sourceTree = ""; }; 2746C74626DF63C800E31BD9 /* SensorPairing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorPairing.swift; sourceTree = ""; }; 274E71D2297ED77300FCFECD /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; - 274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlarmsVolumeView.swift; sourceTree = ""; }; 275786AA26753CC400845D0E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 275EC992265AEE970043210E /* NumericTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumericTextField.swift; sourceTree = ""; }; 275EC997265AF64E0043210E /* StatusMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMessage.swift; sourceTree = ""; }; @@ -402,7 +400,6 @@ children = ( 277773B52639F51300431547 /* CustomDataPickerView.swift */, 276EF5E6264B1FCE00571021 /* AlarmSettingsView.swift */, - 274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */, ); path = AlarmSettings; sourceTree = ""; @@ -999,7 +996,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 274E71D52986D4A600FCFECD /* CriticalAlarmsVolumeView.swift in Sources */, 2746C74226DD0F8800E31BD9 /* Libre2DirectSetup.swift in Sources */, 27ED67BA26990D6B003E5DAB /* GenericObservableObject.swift in Sources */, 27850CFE25672C0C0020D109 /* HKUnit.swift in Sources */, diff --git a/LibreTransmitter/NotificationHelper.swift b/LibreTransmitter/NotificationHelper.swift index 12d9f90..127fbef 100644 --- a/LibreTransmitter/NotificationHelper.swift +++ b/LibreTransmitter/NotificationHelper.swift @@ -30,13 +30,8 @@ public enum NotificationHelper { case calibrationOngoing = "com.loopkit.libremiaomiao.calibration-notification" case libre2directFinishedSetup = "com.loopkit.libremiaomiao.libre2direct-notification" } - - public static var shouldRequestCriticalPermissions = false - - // don't touch this please - public static var criticalAlarmsEnabled = false - - + + public private(set) static var criticalAlarmsEnabled = false private static func vibrate(times: Int=3) { guard times >= 0 else { @@ -52,24 +47,6 @@ public enum NotificationHelper { public static func GlucoseUnitIsSupported(unit: HKUnit) -> Bool { [HKUnit.milligramsPerDeciliter, HKUnit.millimolesPerLiter].contains(unit) } - - private static func requestCriticalNotificationPermissions() { - logger.debug("\(#function) called") - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.badge, .sound, .alert, .criticalAlert]) { (granted, error) in - if granted { - logger.debug("\(#function) was granted") - UNUserNotificationCenter.current().getNotificationSettings { settings in - logPermissions(settings) - criticalAlarmsEnabled = settings.criticalAlertSetting == .enabled - } - } else { - logger.debug("\(#function) failed because of error: \(String(describing: error))") - } - - } - - } private static func logPermissions(_ settings: UNNotificationSettings, caller: String = #function) { @@ -77,21 +54,27 @@ public enum NotificationHelper { } - public static func requestNotificationPermissionsIfNeeded() { - // We assume loop will request necessary "non-critical" permissions for us - // So we are only interested in the "critical" permissions here - + public static func checkCriticalAlertStatus(completion: @escaping (Bool) -> Void) { UNUserNotificationCenter.current().getNotificationSettings { settings in - criticalAlarmsEnabled = settings.criticalAlertSetting == .enabled + let enabled = (settings.criticalAlertSetting == .enabled) + criticalAlarmsEnabled = enabled logPermissions(settings) - - if shouldRequestCriticalPermissions || NotificationHelperOverride.shouldOverrideRequestCriticalPermissions { - requestCriticalNotificationPermissions() + DispatchQueue.main.async { + completion(enabled) } - } } + public static func requestCriticalAlertPermission(completion: @escaping (Bool) -> Void) { + UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert, .criticalAlert]) { _, _ in + checkCriticalAlertStatus(completion: completion) + } + } + + public static func requestNotificationPermissionsIfNeeded() { + checkCriticalAlertStatus { _ in } + } + private static func ensureCanSendNotification(_ completion: @escaping () -> Void ) { UNUserNotificationCenter.current().getNotificationSettings { settings in guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { @@ -348,6 +331,7 @@ public extension NotificationHelper { let alarm = schedules?.getActiveAlarms(glucose.glucoseDouble) ?? .none let isSnoozed = GlucoseScheduleList.isSnoozed() + let shouldOverrideDoNotDisturb = schedules?.shouldOverrideDoNotDisturb(glucose.glucoseDouble) ?? false let shouldShowPhoneBattery = UserDefaults.standard.mmShowPhoneBattery let transmitterBattery = UserDefaults.standard.mmShowTransmitterBattery && battery != nil ? battery : nil @@ -359,7 +343,8 @@ public extension NotificationHelper { if shouldSend || alarm.isAlarming() { sendGlucoseNotification(glucose: glucose, oldValue: oldValue, glucoseFormatter: glucoseFormatter, - alarm: alarm, isSnoozed: isSnoozed, + alarm: alarm, shouldOverrideDoNotDisturb: shouldOverrideDoNotDisturb, + isSnoozed: isSnoozed, trend: trend, showPhoneBattery: shouldShowPhoneBattery, transmitterBattery: transmitterBattery) } else { @@ -371,6 +356,7 @@ public extension NotificationHelper { private static func sendGlucoseNotification(glucose: LibreGlucose, oldValue: LibreGlucose?, glucoseFormatter: QuantityFormatter, alarm: GlucoseScheduleAlarmResult = .none, + shouldOverrideDoNotDisturb: Bool = false, isSnoozed: Bool = false, trend: GlucoseTrend?, showPhoneBattery: Bool = false, @@ -387,10 +373,10 @@ public extension NotificationHelper { titles.append("Glucose") case .low: titles.append("LOWALERT!") - isCritical = true + isCritical = shouldOverrideDoNotDisturb case .high: titles.append("HIGHALERT!") - isCritical = true + isCritical = shouldOverrideDoNotDisturb } if isSnoozed { diff --git a/LibreTransmitter/NotificationHelperOverride.swift b/LibreTransmitter/NotificationHelperOverride.swift index 80daa2c..e146a18 100644 --- a/LibreTransmitter/NotificationHelperOverride.swift +++ b/LibreTransmitter/NotificationHelperOverride.swift @@ -7,9 +7,10 @@ // import Foundation -enum NotificationHelperOverride { - static var shouldOverrideRequestCriticalPermissions : Bool { - // if you want LibreTransmitter to try upgrading to critical notifications, change this +public enum NotificationHelperOverride { + public static var shouldOverrideRequestCriticalPermissions : Bool { + // if you want LibreTransmitter to override whether it shows the UI/banner for + // the critical notification permissions flow, change this false } } diff --git a/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift b/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift index 0467ba4..086f1f4 100644 --- a/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift @@ -8,6 +8,7 @@ import SwiftUI import HealthKit +import LibreTransmitter private func systemImage(_ name:String) -> some View { Image(systemName: name) @@ -24,6 +25,7 @@ class AlarmScheduleState: ObservableObject, Identifiable, Hashable { @Published var lowmgdl: Double = 72 @Published var highmgdl: Double = 180 @Published var enabled: Bool? = false + @Published var overrideDoNotDisturb: Bool? = false @Published var alarmDateComponents: AlarmTimeCellExternalState = AlarmTimeCellExternalState() @@ -114,6 +116,7 @@ class AlarmSettingsState: ObservableObject { schedule.enabled = storedState.schedules[i].enabled schedule.lowmgdl = storedState.schedules[i].lowAlarm ?? -1 schedule.highmgdl = storedState.schedules[i].highAlarm ?? -1 + schedule.overrideDoNotDisturb = storedState.schedules[i].overrideDoNotDisturb schedule.alarmDateComponents.startComponents = storedState.schedules[i].from schedule.alarmDateComponents.endComponents = storedState.schedules[i].to @@ -138,7 +141,9 @@ class AlarmSettingsState: ObservableObject { glucoseSchedule.highAlarm = newStateSchedule.highmgdl glucoseSchedule.from = newStateSchedule.alarmDateComponents.startComponents glucoseSchedule.to = newStateSchedule.alarmDateComponents.endComponents - + glucoseSchedule.overrideDoNotDisturb = newStateSchedule.overrideDoNotDisturb + + legacyState.schedules.append(glucoseSchedule) } @@ -285,6 +290,103 @@ struct AlarmHighRow: View { } } +struct OverrideDoNotDisturbRow: View { + @ObservedObject var schedule: AlarmScheduleState + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center) { + systemImage("bell.badge.fill") + .frame(maxWidth: 50, alignment: .leading) + Text(LocalizedString("Override Do Not Disturb", comment: "Text describing that 'Do Not Disturb' will overriden by this alarm")) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle("", isOn: Binding( + get: { schedule.overrideDoNotDisturb == true }, + set: { schedule.overrideDoNotDisturb = $0 } + )) + .frame(maxWidth: 50, alignment: .trailing) + } + + if schedule.overrideDoNotDisturb == true { + Text(LocalizedString("This alarm will sound even in Do Not Disturb mode", comment: "Text that describes that the alarm will sound even in Do Not Disturb mode")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + +struct CriticalAlertsBannerSection: View { + @Binding var criticalAlertsEnabled: Bool + @State private var presentableStatus: StatusMessage? + + var body: some View { + if NotificationHelperOverride.shouldOverrideRequestCriticalPermissions && !criticalAlertsEnabled { + Section { + VStack(alignment: .leading, spacing: 8) { + Label(LocalizedString("Critical Alerts", comment: "Title for the critical alerts banner"), systemImage: "bell.badge") + .font(.headline) + Text(LocalizedString("Enable critical alerts so glucose alarms can sound even when Do Not Disturb or silent mode is on.", comment: "Text describing the functionality of the 'Do Not Disturb' toggle")) + .font(.subheadline) + .foregroundColor(.secondary) + Button(action: requestCriticalAlerts) { + Text(LocalizedString("Enable Critical Alerts", comment: "Button text to request critical alert permissions")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .padding(.top, 4) + } + .padding(.vertical, 4) + } + .alert(item: $presentableStatus) { status in + Alert(title: Text(status.title), message: Text(status.message), dismissButton: .default(Text(LocalizedString("Got it!", comment: "Dismiss button for critical alerts permission alert")))) + } + } + } + + private func requestCriticalAlerts() { + NotificationHelper.requestCriticalAlertPermission { enabled in + criticalAlertsEnabled = enabled + if !enabled { + presentableStatus = StatusMessage( + title: LocalizedString("Could Not Enable Critical Alerts", comment: "Alert title when critical alert permission was not granted"), + message: LocalizedString("Critical alerts were not enabled. If you denied the permission prompt, you can enable them in iOS Settings > Notifications for this app. If this is a development build, also make sure the app has the critical alerts entitlement and that the provisioning profile supports it.", comment: "Alert message explaining how to enable critical alerts if permission was denied") + ) + } + } + } +} + +struct CriticalAlarmsVolumeSection: View { + private enum Key: String { + case mmCriticalAlarmsVolume = "com.loopkit.libreCriticalAlarmsVolume" + } + + @AppStorage(Key.mmCriticalAlarmsVolume.rawValue) var mmCriticalAlarmsVolume: Double = 60 + @State private var isEditing = false + + private var intVolume: Int { + Int(mmCriticalAlarmsVolume) + } + + var body: some View { + Section(header: Text(LocalizedString("Critical alarm volume", comment: "Header describing the volume of the critical alerts")), footer: Text(LocalizedString("Critical alarms will always be sent with volume at minimum 60%", comment: "Text describing that critical alerts are sent at a minimum volume of 60%"))) { + Slider( + value: $mmCriticalAlarmsVolume, + in: 60...100, + step: 5, + onEditingChanged: { editing in + isEditing = editing + } + ) + Text("\(intVolume)%") + .foregroundColor(isEditing ? .red : .blue) + } + } +} + struct AlarmSettingsView: View { private(set) var glucoseUnit: HKUnit @@ -304,6 +406,8 @@ struct AlarmSettingsView: View { // for accessing the alarm section @State private var requiresAuthentication = Features.alarmSettingsViewRequiresAuthentication + @State private var criticalAlertsEnabled = false + var body: some View { erasedWithKeyboardDismissal(list) .alert(item: $presentableStatus) { status in @@ -318,10 +422,18 @@ struct AlarmSettingsView: View { } } + checkCriticalAlertStatus() + } .disabled(requiresAuthentication ? !authSuccess : false) } + private func checkCriticalAlertStatus() { + NotificationHelper.checkCriticalAlertStatus { enabled in + criticalAlertsEnabled = enabled + } + } + func erasedWithKeyboardDismissal(_ view: any View) -> AnyView { if #available(iOS 16.0, *) { return AnyView(view.scrollDismissesKeyboard(.immediately)) @@ -333,19 +445,27 @@ struct AlarmSettingsView: View { @StateObject var errorReporter = FormErrorState() var list: some View { - List { + CriticalAlertsBannerSection(criticalAlertsEnabled: $criticalAlertsEnabled) + ForEach(Array(alarmState.schedules.enumerated()), id: \.1) { i, schedule in Section(header: Text(LocalizedString("Schedule ", comment: "Text describing schedule in alarmsettingsview") + "\(i+1)")) { AlarmDateRow(schedule: schedule, tag: i, subviewSelection: $subviewSelection) AlarmLowRow(schedule: schedule, glucoseUnit: glucoseUnit, glucoseUnitDesc: glucoseUnitDesc, errorReporter: errorReporter) AlarmHighRow(schedule: schedule, glucoseUnit: glucoseUnit, glucoseUnitDesc: glucoseUnitDesc, errorReporter: errorReporter) + if criticalAlertsEnabled { + OverrideDoNotDisturbRow(schedule: schedule) + } }.onTapGesture { self.hideKeyboardPreIos16() } } + + if criticalAlertsEnabled { + CriticalAlarmsVolumeSection() + } Section { Button("Save") { diff --git a/LibreTransmitterUI/Views/Settings/AlarmSettings/CriticalAlarmsVolumeView.swift b/LibreTransmitterUI/Views/Settings/AlarmSettings/CriticalAlarmsVolumeView.swift deleted file mode 100644 index cf76b4f..0000000 --- a/LibreTransmitterUI/Views/Settings/AlarmSettings/CriticalAlarmsVolumeView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CriticalAlarmsVolumeView.swift -// LibreTransmitterUI -// -// Created by LoopKit Authors on 29/01/2023. -// Copyright © 2023 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -struct CriticalAlarmsVolumeView: View { - - private var intVolume : Int { - Int(mmCriticalAlarmsVolume) - } - @State private var isEditing = false - - private enum Key: String { - case mmCriticalAlarmsVolume = "com.loopkit.libreCriticalAlarmsVolume" - } - - @AppStorage(Key.mmCriticalAlarmsVolume.rawValue) var mmCriticalAlarmsVolume: Double = 60 - - var body: some View { - List { - Section(header: Text("Critical alarm volume"), footer: Text("Critical alarms will always be sent with volume at minimum 60%")) { - Slider( - value: $mmCriticalAlarmsVolume, - in: 60...100, - step: 5, - onEditingChanged: { editing in - isEditing = editing - } - ) - Text("\(intVolume)%") - .foregroundColor(isEditing ? .red : .blue) - - } - } - } -} - -struct CriticalAlarmsVolumeView_Previews: PreviewProvider { - static var previews: some View { - CriticalAlarmsVolumeView() - } -} diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index ac1b6e1..ade7dc7 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -265,12 +265,6 @@ struct SettingsView: View { SettingsItem(title: "Alarms") } - if NotificationHelper.criticalAlarmsEnabled { - NavigationLink(destination: CriticalAlarmsVolumeView()) { - SettingsItem(title: "Critical Alarms volume") - } - } - NavigationLink(destination: GlucoseSettingsView()) { SettingsItem(title: "Glucose Settings") }