Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Common/Settings/GlucoseSchedules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,27 @@ 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 {
var from: DateComponents?
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)
Expand Down Expand Up @@ -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)))"
}
}
4 changes: 0 additions & 4 deletions LibreTransmitter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -213,7 +212,6 @@
2746C74426DF636900E31BD9 /* SensorPairingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorPairingService.swift; sourceTree = "<group>"; };
2746C74626DF63C800E31BD9 /* SensorPairing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorPairing.swift; sourceTree = "<group>"; };
274E71D2297ED77300FCFECD /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlarmsVolumeView.swift; sourceTree = "<group>"; };
275786AA26753CC400845D0E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
275EC992265AEE970043210E /* NumericTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumericTextField.swift; sourceTree = "<group>"; };
275EC997265AF64E0043210E /* StatusMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMessage.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -402,7 +400,6 @@
children = (
277773B52639F51300431547 /* CustomDataPickerView.swift */,
276EF5E6264B1FCE00571021 /* AlarmSettingsView.swift */,
274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */,
);
path = AlarmSettings;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
60 changes: 23 additions & 37 deletions LibreTransmitter/NotificationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -52,46 +47,34 @@ 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) {

logger.debug("\(caller): alarms allowed: \(String(describing:settings.authorizationStatus)). Critical alarms allowed? \(String(describing:settings.criticalAlertSetting))")

}

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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestCriticalAlertPermission discards the granted and error values returned by requestAuthorization. Using those values would allow the UI to show a more accurate message (e.g. denied vs. missing entitlement) and avoid an extra round-trip in some cases.

Suggested change
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert, .criticalAlert]) { _, _ in
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert, .criticalAlert]) { granted, error in
if let error {
logger.debug("\(#function) failed to request critical alert permission: \(error.localizedDescription)")
criticalAlarmsEnabled = false
DispatchQueue.main.async {
completion(false)
}
return
}
guard granted else {
logger.debug("\(#function) critical alert permission denied")
criticalAlarmsEnabled = false
DispatchQueue.main.async {
completion(false)
}
return
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is meaningful.

requestCriticalAlertPermission is called from the CriticalAlertsBannerSection which already shows a proper message in case the result is not .enabled

If the user denied it then it is clear what the reason is. Otherwise there is only possible reason left: the app was not built with the correct entitlements.

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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions LibreTransmitter/NotificationHelperOverride.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import SwiftUI
import HealthKit
import LibreTransmitter

private func systemImage(_ name:String) -> some View {
Image(systemName: name)
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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)

}
Expand Down Expand Up @@ -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<Bool>(
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)
Comment thread
ETolboom marked this conversation as resolved.
.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
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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)
}
Comment on lines 455 to +458
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-schedule "Override Do Not Disturb" toggle is only rendered when criticalAlertsEnabled is true. This means users can’t preconfigure overrides before enabling the permission (and it also partially contradicts the PR description that the toggle is added to each schedule). Consider always showing the row but disabling it (or showing explanatory text) when Critical Alerts aren’t enabled.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally think this is still proper.

If the user does not have the permission, the toggles nor the volume slider have any effect. Only after the user has set shouldOverrideDoNotDisturb to true and given critical alert permissions should the functionality be available.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Override Do Not Disturb" might be confusing to have enabled on a per schedule basis, it should ideally be set for all schedules at once. I might be misunderstanding the intention or use-case though. My intention is that a user will rather use the "silence/snooze" feature for situations where no audible alarms are required.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Override Do Not Disturb" might be confusing to have enabled on a per schedule basis, it should ideally be set for all schedules at once. I might be misunderstanding the intention or use-case though. My intention is that a user will rather use the "silence/snooze" feature for situations where no audible alarms are required.

Hi,

I had envisioned it like this:
Schedule 1 and schedule 2 would represent day time and night time schedules, respectively.

Schedule 1 will present HIGH and LOW alarms as a normal notification.

Schedule 2 will present the same but then with audible alarms through the use of critical alerts.

My reasoning was that for overnight lows its critical I hear them. Critical alerts allow a minimum volume and can bypass any focus profiles. During the day I'm usually on my phone enough (or apple watch) such that the notifications will tell me enough. Furthemore, I often have classes and meetings during the day so that requires my phone to be on silent.


}.onTapGesture {
self.hideKeyboardPreIos16()
}

}

if criticalAlertsEnabled {
CriticalAlarmsVolumeSection()
}

Section {
Button("Save") {
Expand Down
Loading