From f643ff7a6132a0fb842c1994aa7f1063d6cbb52e Mon Sep 17 00:00:00 2001 From: ETolboom Date: Mon, 17 Aug 2026 23:25:57 +0200 Subject: [PATCH 01/15] refactor: add cgm lifecycle progress and support new alerting system --- .gitignore | 3 - Common/Settings/GlucoseSchedules.swift | 223 -------- .../Settings/UserDefaults+Alarmsettings.swift | 202 ------- Common/Settings/UserDefaults+Bluetooth.swift | 17 + Features.swift | 10 +- LibreTransmitter.xcodeproj/project.pbxproj | 72 +-- LibreTransmitter/.gitignore | 1 - .../Alerting/LibreAlertCondition.swift | 89 ++++ .../Alerting/LibreSensorLifecycle.swift | 78 +++ .../LibreTransmitterManagerV3+Alerts.swift | 38 ++ .../LibreTransmitterManager+Libre2EU.swift | 7 - ...LibreTransmitterManager+Transmitters.swift | 21 +- .../LibreTransmitterManagerV3.swift | 61 ++- LibreTransmitter/NotificationHelper.swift | 491 ------------------ .../NotificationHelperOverride.swift | 15 - .../Observables/AlarmStatus.swift | 21 - .../LibreTransmitterManager+UI.swift | 73 ++- .../AlarmSettings/AlarmSettingsView.swift | 380 -------------- .../CriticalAlarmsVolumeView.swift | 47 -- .../AlarmSettings/CustomDataPickerView.swift | 260 ---------- .../Settings/NotificationSettingsView.swift | 135 ----- .../Views/Settings/SettingsView.swift | 40 +- .../Views/Settings/SnoozeView.swift | 141 ----- .../Views/Setup/BluetoothSelection.swift | 1 - .../Views/Setup/Libre2DirectSetup.swift | 5 - build.md | 47 +- 26 files changed, 350 insertions(+), 2128 deletions(-) delete mode 100644 Common/Settings/GlucoseSchedules.swift delete mode 100644 Common/Settings/UserDefaults+Alarmsettings.swift delete mode 100644 LibreTransmitter/.gitignore create mode 100644 LibreTransmitter/Alerting/LibreAlertCondition.swift create mode 100644 LibreTransmitter/Alerting/LibreSensorLifecycle.swift create mode 100644 LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift delete mode 100644 LibreTransmitter/NotificationHelper.swift delete mode 100644 LibreTransmitter/NotificationHelperOverride.swift delete mode 100644 LibreTransmitter/Observables/AlarmStatus.swift delete mode 100644 LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift delete mode 100644 LibreTransmitterUI/Views/Settings/AlarmSettings/CriticalAlarmsVolumeView.swift delete mode 100644 LibreTransmitterUI/Views/Settings/AlarmSettings/CustomDataPickerView.swift delete mode 100644 LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift delete mode 100644 LibreTransmitterUI/Views/Settings/SnoozeView.swift diff --git a/.gitignore b/.gitignore index 5917b40..5c13bfd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -#project Specific files -/LibreTransmitter/NotificationHelperOverride.swift - # OS X .DS_Store diff --git a/Common/Settings/GlucoseSchedules.swift b/Common/Settings/GlucoseSchedules.swift deleted file mode 100644 index 528d31a..0000000 --- a/Common/Settings/GlucoseSchedules.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// GlucoseSchedules.swift -// MiaomiaoClient -// -// Created by LoopKit Authors on 19/04/2019. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import HealthKit -// import MiaomiaoClient -public enum GlucoseScheduleAlarmResult: Int, CaseIterable { - case none = 0 - case low - case high - - func isAlarming() -> Bool { - rawValue != GlucoseScheduleAlarmResult.none.rawValue - } -} - -enum GlucoseSchedulesValidationStatus { - case success - case error(String) -} - -class GlucoseScheduleList: Codable, CustomStringConvertible { - var description: String { - "(schedules: \(schedules) )" - } - - public var schedules = [GlucoseSchedule]() - - public var enabledSchedules: [GlucoseSchedule] { - schedules.compactMap({ $0.enabled == true ? $0 : nil }) - } - - // this is only used by the ui to count total number of schedules - public static let minimumSchedulesCount = 2 - - public var activeSchedules: [GlucoseSchedule] { - enabledSchedules.compactMap { - if let activeTime = $0.getScheduleActiveToFrom() { - let now = Date() - return activeTime.contains(now) ? $0 : nil - } - return nil - } - } - - private func validateGlucoseThresholds() -> GlucoseSchedulesValidationStatus? { - // This is on purpose - // we check all chedules for valid thresholds - for schedule in self.schedules { - if let low = schedule.lowAlarm, let high = schedule.highAlarm { - if low == high { - return .error("One of your glucose schedules had the same value for low and high thresholds") - } - if low > high { - return .error("One of your glucose schedules had a low threshold set above your high threshold") - } - // just for completness sake, this would never be called - if high < low { - return .error("One of your glucose schedules had a high threshold set below your low threshold") - } - } - } - return nil - } - - public func validateGlucoseSchedules() -> GlucoseSchedulesValidationStatus { - if let errors = validateGlucoseThresholds() { - return errors - } - - // if we have zero or 1 enabled schedules, overlapping would not be possible - // (there is nothing to overlap on), so we skip interval check - guard self.enabledSchedules.count > 1 else { - return .success - } - - var sameStartEnd = false - let intervals: [DateInterval] = enabledSchedules.compactMap({ - var schedule = $0.getScheduleActiveToFrom() - if let start = schedule?.start, let end = schedule?.end { - if start == end { - sameStartEnd = true - return nil - } - } - // This compensates for Datetimes being closed range in nature - // example, - // interval1start = 12:00, interval1end=14:00 - // interval2start = 14:00, interval2end=24:00 - // interval1end and interval2 would collide when .intersect()-ing, - // so we change interval1end to 13:59:59 - // and interval2end to 23:59:59 - // This function is only used in the gui for validation, so this is acceptable - // - if let end = schedule?.end { - if let newEnd = Calendar.current.date(byAdding: .second, value: -1, to: end) { - schedule?.end = newEnd - } - } - return schedule - }) - if sameStartEnd { - return .error("One interval had the same start and end!") - } - if let intersects = intervals.intersect() { - print("Glucose schedule collided, not valid! \(intersects)") - return .error("Glucose schedules had overlapping time intervals") - } - return .success - } - // for convenience - public static var snoozedUntil: Date? { - UserDefaults.standard.snoozedUntil - } - - public static func isSnoozed() -> Bool { - let now = Date() - - if let snoozedUntil { - return snoozedUntil >= now - } - return false - } - - public func getActiveAlarms(_ currentGlucoseInMGDL: Double) -> GlucoseScheduleAlarmResult { - for schedule in self.activeSchedules { - if let lowAlarm = schedule.lowAlarm, currentGlucoseInMGDL <= lowAlarm { - return .low - } - if let highAlarm = schedule.highAlarm, currentGlucoseInMGDL >= highAlarm { - return .high - } - } - return .none - } -} - -class GlucoseSchedule: Codable, CustomStringConvertible { - var from: DateComponents? - var to: DateComponents? - var lowAlarm: Double? - var highAlarm: Double? - var enabled: Bool? - - // glucose schedules are stored as standalone datecomponents (i.e. offsets) - // this takes the current start of day and adds those offsets, - // and returns a Dateinterval with those offsets applied - public func getScheduleActiveToFrom() -> DateInterval? { - guard let fromComponents = from, let toComponents = to else { - return nil - } - - let now = Date() - let previousMidnight = Calendar.current.startOfDay(for: now) - let helper = Calendar.current.date(byAdding: .day, value: 1, to: previousMidnight)! - let nextMidnight = Calendar.current.startOfDay(for: helper) - - let fromDate: Date? = Calendar.current.date(byAdding: fromComponents, to: previousMidnight) - var toDate: Date? - if toComponents.minute == 0 && toComponents.hour == 0 { - toDate = nextMidnight - } else { - toDate = Calendar.current.date(byAdding: toComponents, to: previousMidnight)! - } - - if let fromDate, let toDate, toDate >= fromDate { - return DateInterval(start: fromDate, end: toDate) - } - return nil - } - // stores the alarm. It does not synhronize the value with the underlaying userdefaults - // that is up to the caller of this class - public func storeLowAlarm(forUnit unit: HKUnit, lowAlarm: Double) { - if unit == HKUnit.millimolesPerLiter { - self.lowAlarm = lowAlarm * 18 - return - } - - self.lowAlarm = lowAlarm - } - - public func retrieveLowAlarm(forUnit unit: HKUnit) -> Double? { - if let lowAlarm = self.lowAlarm { - if unit == HKUnit.millimolesPerLiter { - return (lowAlarm / 18).roundTo(places: 1) - } else { - return lowAlarm - } - } - - return nil - } - - // stores the alarm. It does not synhronize the value with the underlaying userdefaults - // that is up to the caller of this class - public func storeHighAlarm(forUnit unit: HKUnit, highAlarm: Double) { - if unit == HKUnit.millimolesPerLiter { - self.highAlarm = highAlarm * 18 - return - } - - self.highAlarm = highAlarm - } - public func retrieveHighAlarm(forUnit unit: HKUnit) -> Double? { - if let highAlarm = self.highAlarm { - if unit == HKUnit.millimolesPerLiter { - return (highAlarm / 18).roundTo(places: 1) - } - return highAlarm - } - - return nil - } - - var description: String { - "(from: \(String(describing: from)), to: \(String(describing: to)), low: \(String(describing: lowAlarm)), high: \(String(describing: highAlarm)), enabled: \(String(describing: enabled)))" - } -} diff --git a/Common/Settings/UserDefaults+Alarmsettings.swift b/Common/Settings/UserDefaults+Alarmsettings.swift deleted file mode 100644 index a05b78e..0000000 --- a/Common/Settings/UserDefaults+Alarmsettings.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// Userdefaults+Alarmsettings.swift -// MiaomiaoClient -// -// Created by LoopKit Authors on 20/04/2019. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation -import HealthKit - -extension UserDefaults { - private enum Key: String { - case glucoseSchedules = "com.loopkit.libreglucoseschedules" - - case mmAlwaysDisplayGlucose = "com.loopkit.libreAlwaysDisplayGlucose" - case mmNotifyEveryXTimes = "com.loopkit.libreNotifyEveryXTimes" - - case mmAlertLowBatteryWarning = "com.loopkit.libreLowBatteryWarning" - case mmAlertInvalidSensorDetected = "com.loopkit.libreInvalidSensorDetected" - // case mmAlertalarmNotifications - case mmAlertNewSensorDetected = "com.loopkit.libreNewSensorDetected" - case mmAlertNoSensorDetected = "com.loopkit.libreNoSensorDetected" - case mmGlucoseUnit = "com.loopkit.libreGlucoseUnit" - case mmAlertSensorSoonExpire = "com.loopkit.libreAlertSensorSoonExpire" - case mmSnoozedUntil = "com.loopkit.libreSnoozedUntil" - case mmDangerMode = "com.loopkit.libreDangerModeActivated" - case mmShowPhoneBattery = "com.loopkit.libreShowPhoneBattery" - case mmShowTransmitterBattery = "com.loopkit.libreShowTransmitterBattery" - case mmCriticalAlarmsVolume = "com.loopkit.libreCriticalAlarmsVolume" - } - - public func optionalBool(forKey defaultName: String) -> Bool? { - if let value = value(forKey: defaultName) { - return value as? Bool - } - return nil - } - - var mmShowPhoneBattery: Bool { - get { - optionalBool(forKey: Key.mmShowPhoneBattery.rawValue) ?? false - } - set { - set(newValue, forKey: Key.mmShowPhoneBattery.rawValue) - } - } - - var mmAlwaysDisplayGlucose: Bool { - get { - optionalBool(forKey: Key.mmAlwaysDisplayGlucose.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlwaysDisplayGlucose.rawValue) - } - } - var mmNotifyEveryXTimes: Int { - get { - integer(forKey: Key.mmNotifyEveryXTimes.rawValue) - } - set { - set(newValue, forKey: Key.mmNotifyEveryXTimes.rawValue) - } - } - - var mmAlertLowBatteryWarning: Bool { - get { - optionalBool(forKey: Key.mmAlertLowBatteryWarning.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlertLowBatteryWarning.rawValue) - } - } - var mmAlertInvalidSensorDetected: Bool { - get { - optionalBool(forKey: Key.mmAlertInvalidSensorDetected.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlertInvalidSensorDetected.rawValue) - } - } - - var mmAlertNewSensorDetected: Bool { - get { - optionalBool(forKey: Key.mmAlertNewSensorDetected.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlertNewSensorDetected.rawValue) - } - } - - var mmAlertNoSensorDetected: Bool { - get { - optionalBool(forKey: Key.mmAlertNoSensorDetected.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlertNoSensorDetected.rawValue) - } - } - - var mmAlertWillSoonExpire: Bool { - get { - optionalBool(forKey: Key.mmAlertSensorSoonExpire.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmAlertSensorSoonExpire.rawValue) - } - } - - - - var mmShowTransmitterBattery: Bool { - get { - optionalBool(forKey: Key.mmShowTransmitterBattery.rawValue) ?? true - } - set { - set(newValue, forKey: Key.mmShowTransmitterBattery.rawValue) - } - } - - var allNotificationToggles: [Bool] { - [mmAlwaysDisplayGlucose, mmAlertLowBatteryWarning, - mmAlertInvalidSensorDetected, mmAlertNewSensorDetected, - mmAlertNoSensorDetected, mmAlertWillSoonExpire, - mmShowPhoneBattery, mmShowTransmitterBattery] - } - - var dangerModeActivated: Bool { - get { - optionalBool(forKey: Key.mmDangerMode.rawValue) ?? false - } - set { - set(newValue, forKey: Key.mmDangerMode.rawValue) - } - } - - // intentionally only supports mgdl and mmol - var mmGlucoseUnit: HKUnit? { - get { - if let textUnit = string(forKey: Key.mmGlucoseUnit.rawValue) { - if textUnit == "mmol" { - return HKUnit.millimolesPerLiter - } else if textUnit == "mgdl" { - return HKUnit.milligramsPerDeciliter - } - } - - return nil - } - set { - if newValue == HKUnit.milligramsPerDeciliter { - set("mgdl", forKey: Key.mmGlucoseUnit.rawValue) - } else if newValue == HKUnit.millimolesPerLiter { - set("mmol", forKey: Key.mmGlucoseUnit.rawValue) - } - } - } - - var enabledSchedules: [GlucoseSchedule]? { - glucoseSchedules?.schedules.compactMap({ schedule -> GlucoseSchedule? in - if schedule.enabled ?? false { - return schedule - } - return nil - }) - } - var snoozedUntil: Date? { - get { - object(forKey: Key.mmSnoozedUntil.rawValue) as? Date - } - set { - set(newValue, forKey: Key.mmSnoozedUntil.rawValue) - } - } - var glucoseSchedules: GlucoseScheduleList? { - get { - if let savedGlucoseSchedules = object(forKey: Key.glucoseSchedules.rawValue) as? Data { - let decoder = JSONDecoder() - if let loadedGlucoseSchedules = try? decoder.decode(GlucoseScheduleList.self, from: savedGlucoseSchedules) { - return loadedGlucoseSchedules - } - } - - return GlucoseScheduleList() - } - set { - let encoder = JSONEncoder() - if let val = newValue, let encoded = try? encoder.encode(val) { - set(encoded, forKey: Key.glucoseSchedules.rawValue) - } - } - } - - var mmCriticalAlarmsVolume: Double { - get { - double(forKey: Key.mmCriticalAlarmsVolume.rawValue) - } - set { - set(newValue, forKey: Key.mmCriticalAlarmsVolume.rawValue) - } - } -} diff --git a/Common/Settings/UserDefaults+Bluetooth.swift b/Common/Settings/UserDefaults+Bluetooth.swift index d17b682..6ef58e1 100644 --- a/Common/Settings/UserDefaults+Bluetooth.swift +++ b/Common/Settings/UserDefaults+Bluetooth.swift @@ -13,6 +13,23 @@ extension UserDefaults { private enum Key: String { case bluetoothDeviceUUIDString = "com.loopkit.librebluetoothDeviceUUIDString" case libre2UiD = "com.loopkit.libre2uid" + case dangerMode = "com.loopkit.libreDangerModeActivated" + } + + public func optionalBool(forKey defaultName: String) -> Bool? { + if let value = value(forKey: defaultName) { + return value as? Bool + } + return nil + } + + var dangerModeActivated: Bool { + get { + optionalBool(forKey: Key.dangerMode.rawValue) ?? false + } + set { + set(newValue, forKey: Key.dangerMode.rawValue) + } } public var preSelectedUid: Data? { diff --git a/Features.swift b/Features.swift index 0253e1d..ec5dde2 100644 --- a/Features.swift +++ b/Features.swift @@ -19,15 +19,7 @@ public final class Features { static public var allowsEditingFactoryCalibrationData = false static public var allowOneMinuteReadings = false - - // Uses Vibration through apples audio api for glucose alarms. This could be considered an api abuse from apple's standpoint; - // since apis invoked for this feature are meant for audio streaming apps. - // However, there are a couple of good reason for keeping this feature, but behind a featureflag rather than a gui toggle: - // * For heavy sleepers, the combination of an alarm at 100% volume + vibration makes it much more likely to wake up during nighttime. - // * For this feature to work as intended, Loops info.plist much be amended with "audio" permissions, - // see Loop->Signing & Capabilities->Background Modes->"Audio, AirPlay, Picture in picture" in xcode - static public var glucoseAlarmsAlsoCauseVibration = false - + static var phoneNFCAvailable: Bool { return NFCNDEFReaderSession.readingAvailable } diff --git a/LibreTransmitter.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index 2e9477d..f2fc525 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -7,8 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 270C1429266047490063405B /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270C1428266047490063405B /* NotificationSettingsView.swift */; }; - 271A39F52975844B0005FEDA /* NotificationHelperOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271A39F42975844B0005FEDA /* NotificationHelperOverride.swift */; }; + D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */; }; + 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */; }; + DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */; }; 2735A6E029637DEA00D4E868 /* LibreTransmitter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 432B0E881CDFC3C50045347B /* LibreTransmitter.framework */; }; 2735A6E129637DEA00D4E868 /* LibreTransmitter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 432B0E881CDFC3C50045347B /* LibreTransmitter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 2735A6E429637DEA00D4E868 /* LibreTransmitterUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC82210E664300A81379 /* LibreTransmitterUI.framework */; }; @@ -21,17 +22,14 @@ 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 */; }; 275EC9AB265EDC280043210E /* GlucoseSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */; }; 2764E86126FC6DC10016A585 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2746C73D26DCF83400E31BD9 /* Features.swift */; }; - 276EF5E7264B1FCE00571021 /* AlarmSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276EF5E6264B1FCE00571021 /* AlarmSettingsView.swift */; }; 276EF5F22652F84F00571021 /* HashableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276EF5F12652F84F00571021 /* HashableClass.swift */; }; 277773A72639EF2B00431547 /* BlueButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277773A62639EF2B00431547 /* BlueButtonStyle.swift */; }; 277773AC2639EFB800431547 /* ErrorTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277773AB2639EFB800431547 /* ErrorTextFieldStyle.swift */; }; - 277773B62639F51300431547 /* CustomDataPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277773B52639F51300431547 /* CustomDataPickerView.swift */; }; 27850CE725672C0C0020D109 /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CD925672C0C0020D109 /* DataExtensions.swift */; }; 27850CE925672C0C0020D109 /* TimeIntervalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CDA25672C0C0020D109 /* TimeIntervalExtensions.swift */; }; 27850CEA25672C0C0020D109 /* TimeIntervalExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CDA25672C0C0020D109 /* TimeIntervalExtensions.swift */; }; @@ -45,26 +43,20 @@ 27850CF225672C0C0020D109 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CDE25672C0C0020D109 /* LocalizedString.swift */; }; 27850CF325672C0C0020D109 /* UserDefaults+GlucoseSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE025672C0C0020D109 /* UserDefaults+GlucoseSettings.swift */; }; 27850CF425672C0C0020D109 /* UserDefaults+GlucoseSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE025672C0C0020D109 /* UserDefaults+GlucoseSettings.swift */; }; - 27850CF525672C0C0020D109 /* UserDefaults+Alarmsettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE125672C0C0020D109 /* UserDefaults+Alarmsettings.swift */; }; - 27850CF625672C0C0020D109 /* UserDefaults+Alarmsettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE125672C0C0020D109 /* UserDefaults+Alarmsettings.swift */; }; 27850CF725672C0C0020D109 /* UserDefaults+Bluetooth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE225672C0C0020D109 /* UserDefaults+Bluetooth.swift */; }; 27850CF825672C0C0020D109 /* UserDefaults+Bluetooth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE225672C0C0020D109 /* UserDefaults+Bluetooth.swift */; }; 27850CF925672C0C0020D109 /* UIApplication+metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE325672C0C0020D109 /* UIApplication+metadata.swift */; }; 27850CFA25672C0C0020D109 /* UIApplication+metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE325672C0C0020D109 /* UIApplication+metadata.swift */; }; - 27850CFB25672C0C0020D109 /* GlucoseSchedules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE425672C0C0020D109 /* GlucoseSchedules.swift */; }; - 27850CFC25672C0C0020D109 /* GlucoseSchedules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE425672C0C0020D109 /* GlucoseSchedules.swift */; }; 27850CFD25672C0C0020D109 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE525672C0C0020D109 /* HKUnit.swift */; }; 27850CFE25672C0C0020D109 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850CE525672C0C0020D109 /* HKUnit.swift */; }; 27850D0E25672CA60020D109 /* ConcreteGlucoseDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D0B25672CA60020D109 /* ConcreteGlucoseDisplayable.swift */; }; 27850D1025672CA60020D109 /* LibreGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D0C25672CA60020D109 /* LibreGlucose.swift */; }; - 27850D1225672CA60020D109 /* NotificationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D0D25672CA60020D109 /* NotificationHelper.swift */; }; 27850D4E25672DFB0020D109 /* LibreTransmitterSetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D2425672DFB0020D109 /* LibreTransmitterSetupViewController.swift */; }; 27850D6325672DFB0020D109 /* miaomiao-small.png in Resources */ = {isa = PBXBuildFile; fileRef = 27850D4025672DFB0020D109 /* miaomiao-small.png */; }; 27850D6425672DFB0020D109 /* libresensor.png in Resources */ = {isa = PBXBuildFile; fileRef = 27850D4125672DFB0020D109 /* libresensor.png */; }; 27850D6525672DFB0020D109 /* bubble.png in Resources */ = {isa = PBXBuildFile; fileRef = 27850D4225672DFB0020D109 /* bubble.png */; }; 27850D6925672DFB0020D109 /* icons8-up-50.png in Resources */ = {isa = PBXBuildFile; fileRef = 27850D4625672DFB0020D109 /* icons8-up-50.png */; }; 27850D6A25672DFB0020D109 /* icons8-down-arrow-50.png in Resources */ = {isa = PBXBuildFile; fileRef = 27850D4725672DFB0020D109 /* icons8-down-arrow-50.png */; }; - 27850D6C25672DFB0020D109 /* SnoozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D4A25672DFB0020D109 /* SnoozeView.swift */; }; 27850D6D25672DFB0020D109 /* BluetoothSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850D4B25672DFB0020D109 /* BluetoothSelection.swift */; }; 27850D9125672F020020D109 /* CBPeripheralExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BC7256725E70020D109 /* CBPeripheralExtensions.swift */; }; 27850D9225672F020020D109 /* LibreTransmitterMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BC6256725E70020D109 /* LibreTransmitterMetadata.swift */; }; @@ -94,7 +86,6 @@ 27DD8F26268FA75A00649010 /* SensorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DD8F25268FA75A00649010 /* SensorInfo.swift */; }; 27DD8F28268FA79800649010 /* GlucoseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DD8F27268FA79800649010 /* GlucoseInfo.swift */; }; 27DD8F2A269106A100649010 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DD8F29269106A100649010 /* ViewExtensions.swift */; }; - 27ED67B82698EF38003E5DAB /* AlarmStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ED67B72698EF38003E5DAB /* AlarmStatus.swift */; }; 27ED67BA26990D6B003E5DAB /* GenericObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ED67B926990D6B003E5DAB /* GenericObservableObject.swift */; }; 27F93B2A2816B7FF00EE39A7 /* LibreTransmitterManager+Libre2EU.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F93B292816B7FF00EE39A7 /* LibreTransmitterManager+Libre2EU.swift */; }; 27F93B2C2816B93900EE39A7 /* LibreTransmitterManager+Transmitters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F93B2B2816B93900EE39A7 /* LibreTransmitterManager+Transmitters.swift */; }; @@ -202,9 +193,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreSensorLifecycle.swift; sourceTree = ""; }; + CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreAlertCondition.swift; sourceTree = ""; }; + 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreTransmitterManagerV3+Alerts.swift"; sourceTree = ""; }; 2709A6F229E5E0D3004355A3 /* FakeSensorPairingData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeSensorPairingData.swift; sourceTree = ""; }; - 270C1428266047490063405B /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; - 271A39F42975844B0005FEDA /* NotificationHelperOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHelperOverride.swift; sourceTree = ""; }; 27394068296C2A48001DD18D /* libresensor200.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = libresensor200.png; sourceTree = ""; }; 274557C72979EA38003B027B /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = ""; }; 2746C73B26D91FC900E31BD9 /* Libre2DirectTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Libre2DirectTransmitter.swift; sourceTree = ""; }; @@ -213,16 +205,13 @@ 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 = ""; }; 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSettingsView.swift; sourceTree = ""; }; - 276EF5E6264B1FCE00571021 /* AlarmSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSettingsView.swift; sourceTree = ""; }; 276EF5F12652F84F00571021 /* HashableClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashableClass.swift; sourceTree = ""; }; 277773A62639EF2B00431547 /* BlueButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueButtonStyle.swift; sourceTree = ""; }; 277773AB2639EFB800431547 /* ErrorTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTextFieldStyle.swift; sourceTree = ""; }; - 277773B52639F51300431547 /* CustomDataPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDataPickerView.swift; sourceTree = ""; }; 27850BA0256724EF0020D109 /* LibreError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreError.swift; sourceTree = ""; }; 27850BA1256724EF0020D109 /* Calibration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calibration.swift; sourceTree = ""; }; 27850BA2256724EF0020D109 /* LimitedQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitedQueue.swift; sourceTree = ""; }; @@ -251,21 +240,17 @@ 27850CDD25672C0C0020D109 /* CollectionExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionExtensions.swift; sourceTree = ""; }; 27850CDE25672C0C0020D109 /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; 27850CE025672C0C0020D109 /* UserDefaults+GlucoseSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+GlucoseSettings.swift"; sourceTree = ""; }; - 27850CE125672C0C0020D109 /* UserDefaults+Alarmsettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Alarmsettings.swift"; sourceTree = ""; }; 27850CE225672C0C0020D109 /* UserDefaults+Bluetooth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Bluetooth.swift"; sourceTree = ""; }; 27850CE325672C0C0020D109 /* UIApplication+metadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIApplication+metadata.swift"; sourceTree = ""; }; - 27850CE425672C0C0020D109 /* GlucoseSchedules.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseSchedules.swift; sourceTree = ""; }; 27850CE525672C0C0020D109 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; 27850D0B25672CA60020D109 /* ConcreteGlucoseDisplayable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConcreteGlucoseDisplayable.swift; sourceTree = ""; }; 27850D0C25672CA60020D109 /* LibreGlucose.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibreGlucose.swift; sourceTree = ""; }; - 27850D0D25672CA60020D109 /* NotificationHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationHelper.swift; sourceTree = ""; }; 27850D2425672DFB0020D109 /* LibreTransmitterSetupViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibreTransmitterSetupViewController.swift; sourceTree = ""; }; 27850D4025672DFB0020D109 /* miaomiao-small.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "miaomiao-small.png"; sourceTree = ""; }; 27850D4125672DFB0020D109 /* libresensor.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = libresensor.png; sourceTree = ""; }; 27850D4225672DFB0020D109 /* bubble.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = bubble.png; sourceTree = ""; }; 27850D4625672DFB0020D109 /* icons8-up-50.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icons8-up-50.png"; sourceTree = ""; }; 27850D4725672DFB0020D109 /* icons8-down-arrow-50.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icons8-down-arrow-50.png"; sourceTree = ""; }; - 27850D4A25672DFB0020D109 /* SnoozeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnoozeView.swift; sourceTree = ""; }; 27850D4B25672DFB0020D109 /* BluetoothSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothSelection.swift; sourceTree = ""; }; 2793EBE5260BDE43001A35A3 /* CalibrationEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationEditView.swift; sourceTree = ""; }; 27A5912626E0E32C00A7EE36 /* ModeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeSelectionView.swift; sourceTree = ""; }; @@ -274,7 +259,6 @@ 27DD8F25268FA75A00649010 /* SensorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorInfo.swift; sourceTree = ""; }; 27DD8F27268FA79800649010 /* GlucoseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseInfo.swift; sourceTree = ""; }; 27DD8F29269106A100649010 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; - 27ED67B72698EF38003E5DAB /* AlarmStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmStatus.swift; sourceTree = ""; }; 27ED67B926990D6B003E5DAB /* GenericObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericObservableObject.swift; sourceTree = ""; }; 27F1592E26CAF6BE00EBA666 /* GenericThrottler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericThrottler.swift; sourceTree = ""; }; 27F93B292816B7FF00EE39A7 /* LibreTransmitterManager+Libre2EU.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreTransmitterManager+Libre2EU.swift"; sourceTree = ""; }; @@ -397,16 +381,6 @@ path = Styles; sourceTree = ""; }; - 277773B42639F4E300431547 /* AlarmSettings */ = { - isa = PBXGroup; - children = ( - 277773B52639F51300431547 /* CustomDataPickerView.swift */, - 276EF5E6264B1FCE00571021 /* AlarmSettingsView.swift */, - 274E71D42986D4A600FCFECD /* CriticalAlarmsVolumeView.swift */, - ); - path = AlarmSettings; - sourceTree = ""; - }; 27850B9F256724EE0020D109 /* LibreSensor */ = { isa = PBXGroup; children = ( @@ -495,10 +469,8 @@ isa = PBXGroup; children = ( 27850CE025672C0C0020D109 /* UserDefaults+GlucoseSettings.swift */, - 27850CE125672C0C0020D109 /* UserDefaults+Alarmsettings.swift */, 27850CE225672C0C0020D109 /* UserDefaults+Bluetooth.swift */, 27850CE325672C0C0020D109 /* UIApplication+metadata.swift */, - 27850CE425672C0C0020D109 /* GlucoseSchedules.swift */, ); path = Settings; sourceTree = ""; @@ -540,11 +512,8 @@ isa = PBXGroup; children = ( 275786AA26753CC400845D0E /* SettingsView.swift */, - 27850D4A25672DFB0020D109 /* SnoozeView.swift */, 2793EBE5260BDE43001A35A3 /* CalibrationEditView.swift */, 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */, - 270C1428266047490063405B /* NotificationSettingsView.swift */, - 277773B42639F4E300431547 /* AlarmSettings */, ); path = Settings; sourceTree = ""; @@ -555,12 +524,21 @@ 27DD8F22268FA0F500649010 /* TransmitterInfo.swift */, 27DD8F25268FA75A00649010 /* SensorInfo.swift */, 27DD8F27268FA79800649010 /* GlucoseInfo.swift */, - 27ED67B72698EF38003E5DAB /* AlarmStatus.swift */, C1AF65972A49C902008D0690 /* SelectionState.swift */, ); path = Observables; sourceTree = ""; }; + A8D5D1B79EF34BA3AEC8C642 /* Alerting */ = { + isa = PBXGroup; + children = ( + 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */, + CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */, + 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */, + ); + path = Alerting; + sourceTree = ""; + }; 432B0E7E1CDFC3C50045347B = { isa = PBXGroup; children = ( @@ -595,9 +573,8 @@ children = ( C1C318D42A47637100C6F29F /* Mocks */, 27ED67B62698EEEF003E5DAB /* Observables */, + A8D5D1B79EF34BA3AEC8C642 /* Alerting */, 27850D0B25672CA60020D109 /* ConcreteGlucoseDisplayable.swift */, - 27850D0D25672CA60020D109 /* NotificationHelper.swift */, - 271A39F42975844B0005FEDA /* NotificationHelperOverride.swift */, 27850D0C25672CA60020D109 /* LibreGlucose.swift */, B66D1F722E6A813000471149 /* Localizable.xcstrings */, 43A8EC9C210E68CE00A81379 /* LibreTransmitterManagerV3.swift */, @@ -941,6 +918,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */, + 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */, + DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */, 27850CF125672C0C0020D109 /* LocalizedString.swift in Sources */, 27850D1025672CA60020D109 /* LibreGlucose.swift in Sources */, 27850D9E25672F170020D109 /* UUIDContainer.swift in Sources */, @@ -949,7 +929,6 @@ 2746C73C26D91FC900E31BD9 /* Libre2DirectTransmitter.swift in Sources */, 27F93B2C2816B93900EE39A7 /* LibreTransmitterManager+Transmitters.swift in Sources */, C1AF65982A49C902008D0690 /* SelectionState.swift in Sources */, - 27850CFB25672C0C0020D109 /* GlucoseSchedules.swift in Sources */, C1C318CD2A47468D00C6F29F /* BluetoothSearch.swift in Sources */, 27850D9125672F020020D109 /* CBPeripheralExtensions.swift in Sources */, 27850DA625672F1E0020D109 /* MiaomiaoTransmitter.swift in Sources */, @@ -978,23 +957,19 @@ 27850DA525672F1C0020D109 /* LibreTransmitterProxyManager.swift in Sources */, 27850CF725672C0C0020D109 /* UserDefaults+Bluetooth.swift in Sources */, C1AF1D122A4BC18400F46A26 /* MockSensorData.swift in Sources */, - 271A39F52975844B0005FEDA /* NotificationHelperOverride.swift in Sources */, 27850CE725672C0C0020D109 /* DataExtensions.swift in Sources */, - 27850D1225672CA60020D109 /* NotificationHelper.swift in Sources */, 27850CF325672C0C0020D109 /* UserDefaults+GlucoseSettings.swift in Sources */, 27850CE925672C0C0020D109 /* TimeIntervalExtensions.swift in Sources */, C1D819B32AC9B60100C9416E /* LimitedQueue.swift in Sources */, 27DD8F28268FA79800649010 /* GlucoseInfo.swift in Sources */, 27850DD025672F780020D109 /* PreLibre2.swift in Sources */, 27DD8F23268FA0F500649010 /* TransmitterInfo.swift in Sources */, - 27850CF525672C0C0020D109 /* UserDefaults+Alarmsettings.swift in Sources */, 27850D9225672F020020D109 /* LibreTransmitterMetadata.swift in Sources */, 27DD8F26268FA75A00649010 /* SensorInfo.swift in Sources */, 27850DC425672F640020D109 /* GlucoseSmoothing.swift in Sources */, 27F93B2A2816B7FF00EE39A7 /* LibreTransmitterManager+Libre2EU.swift in Sources */, 27850DCF25672F780020D109 /* CRC.swift in Sources */, 2764E86126FC6DC10016A585 /* Features.swift in Sources */, - 27ED67B82698EF38003E5DAB /* AlarmStatus.swift in Sources */, 27850CFD25672C0C0020D109 /* HKUnit.swift in Sources */, 27850CF925672C0C0020D109 /* UIApplication+metadata.swift in Sources */, ); @@ -1004,7 +979,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 */, @@ -1016,26 +990,20 @@ 274E71D3297ED77300FCFECD /* AuthView.swift in Sources */, 27850CEA25672C0C0020D109 /* TimeIntervalExtensions.swift in Sources */, 275EC998265AF64E0043210E /* StatusMessage.swift in Sources */, - 27850D6C25672DFB0020D109 /* SnoozeView.swift in Sources */, 274557C92979EA38003B027B /* LoggerExtension.swift in Sources */, - 276EF5E7264B1FCE00571021 /* AlarmSettingsView.swift in Sources */, 27850D6D25672DFB0020D109 /* BluetoothSelection.swift in Sources */, - 270C1429266047490063405B /* NotificationSettingsView.swift in Sources */, 2793EBE6260BDE43001A35A3 /* CalibrationEditView.swift in Sources */, 27850CF225672C0C0020D109 /* LocalizedString.swift in Sources */, - 27850CF625672C0C0020D109 /* UserDefaults+Alarmsettings.swift in Sources */, 27850CEE25672C0C0020D109 /* DateExtensions.swift in Sources */, 27850CF425672C0C0020D109 /* UserDefaults+GlucoseSettings.swift in Sources */, 27A5912726E0E32C00A7EE36 /* ModeSelectionView.swift in Sources */, 2746C73F26DCF83700E31BD9 /* Features.swift in Sources */, 27850CEC25672C0C0020D109 /* DoubleExtensions.swift in Sources */, 277773AC2639EFB800431547 /* ErrorTextFieldStyle.swift in Sources */, - 277773B62639F51300431547 /* CustomDataPickerView.swift in Sources */, 43A8EC91210E676500A81379 /* LibreTransmitterManager+UI.swift in Sources */, 27850CFA25672C0C0020D109 /* UIApplication+metadata.swift in Sources */, 27850CF025672C0C0020D109 /* CollectionExtensions.swift in Sources */, 277773A72639EF2B00431547 /* BlueButtonStyle.swift in Sources */, - 27850CFC25672C0C0020D109 /* GlucoseSchedules.swift in Sources */, 27DD8F2A269106A100649010 /* ViewExtensions.swift in Sources */, 275EC993265AEE970043210E /* NumericTextField.swift in Sources */, ); diff --git a/LibreTransmitter/.gitignore b/LibreTransmitter/.gitignore deleted file mode 100644 index b626749..0000000 --- a/LibreTransmitter/.gitignore +++ /dev/null @@ -1 +0,0 @@ -NotificationHelperOverride.swift diff --git a/LibreTransmitter/Alerting/LibreAlertCondition.swift b/LibreTransmitter/Alerting/LibreAlertCondition.swift new file mode 100644 index 0000000..7fa645d --- /dev/null +++ b/LibreTransmitter/Alerting/LibreAlertCondition.swift @@ -0,0 +1,89 @@ +// +// LibreAlertCondition.swift +// LibreTransmitter +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// Sensor-lifecycle conditions that are surfaced to the user via LoopKit's native +/// Alert framework (`issueAlert`/`retractAlert`). Glucose-threshold alerting is owned +/// by the app (Trio), not by this plugin. +public enum LibreAlertCondition: String, CaseIterable, Sendable { + case signalLost + case expired + case failed + case unactivated + + public static func currentlyFiring(for lifecycle: LibreSensorLifecycle) -> Set { + switch lifecycle { + case .signalLost: + return [.signalLost] + case .expired: + return [.expired] + case .failed: + return [.failed] + case .unactivated: + return [.unactivated] + case .active, .noSensor, .warmup: + return [] + } + } + + public func identifier(managerIdentifier: String) -> Alert.Identifier { + Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "libre.\(rawValue)") + } + + private var title: String { + switch self { + case .signalLost: + return LocalizedString("Signal Loss", comment: "Title for signal loss sensor alert") + case .expired: + return LocalizedString("Sensor Expired", comment: "Title for sensor expired alert") + case .failed: + return LocalizedString("Sensor Malfunction", comment: "Title for sensor malfunction alert") + case .unactivated: + return LocalizedString("Sensor Not Detected", comment: "Title for sensor not detected alert") + } + } + + private var body: String { + switch self { + case .signalLost: + return LocalizedString("Signal lost. Check that your sensor is nearby and Bluetooth is on.", comment: "Body for signal loss sensor alert") + case .expired: + return LocalizedString("Sensor expired. Replace your sensor now.", comment: "Body for sensor expired alert") + case .failed: + return LocalizedString("Sensor malfunction. Replace your sensor now.", comment: "Body for sensor malfunction alert") + case .unactivated: + return LocalizedString("Sensor not detected or reporting as invalid. If this continues, remove and re-pair it.", comment: "Body for sensor not detected alert") + } + } + + private var interruptionLevel: Alert.InterruptionLevel { + switch self { + case .signalLost, .expired: + return .timeSensitive + case .failed, .unactivated: + return .critical + } + } + + public func alert(managerIdentifier: String) -> Alert { + let content = Alert.Content( + title: title, + body: body, + acknowledgeActionButtonLabel: LocalizedString("OK", comment: "Alert acknowledge button") + ) + return Alert( + identifier: identifier(managerIdentifier: managerIdentifier), + foregroundContent: content, + backgroundContent: content, + trigger: .immediate, + interruptionLevel: interruptionLevel + ) + } +} diff --git a/LibreTransmitter/Alerting/LibreSensorLifecycle.swift b/LibreTransmitter/Alerting/LibreSensorLifecycle.swift new file mode 100644 index 0000000..fd24431 --- /dev/null +++ b/LibreTransmitter/Alerting/LibreSensorLifecycle.swift @@ -0,0 +1,78 @@ +// +// LibreSensorLifecycle.swift +// LibreTransmitter +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// A unified view of sensor lifecycle state across both LibreTransmitter data paths +/// (classic BLE transmitters like MiaoMiao/Bubble, and direct Libre2 BLE). +public enum LibreSensorLifecycle: Equatable { + case noSensor + case warmup(progress: Double, remaining: TimeInterval) + case active(remaining: TimeInterval, total: TimeInterval) + case expired + case signalLost(since: Date) + case failed + case unactivated + + /// A normalized, cross-data-path representation of a hard sensor fault. + public enum FaultKind: Equatable { + case sensorFailure + case encryptedOrUnsupported + case noSensorFound + } + + static let signalLostThreshold: TimeInterval = .minutes(6) + + public static func compute( + sensorPaired: Bool, + activatedAt: Date?, + expiresAt: Date?, + latestReadingAt: Date?, + sensorMaxMinutesWearTime: Int, + sensorState: SensorState?, + lastFault: FaultKind?, + now: Date = Date() + ) -> LibreSensorLifecycle { + guard sensorPaired else { + return .noSensor + } + + switch lastFault { + case .sensorFailure: + return .failed + case .encryptedOrUnsupported, .noSensorFound: + return .unactivated + case nil: + break + } + + guard let activatedAt, let expiresAt, sensorMaxMinutesWearTime > 0 else { + return .noSensor + } + + let age = now.timeIntervalSince(activatedAt) + let wear = TimeInterval(minutes: Double(sensorMaxMinutesWearTime)) + + if age >= wear || now >= expiresAt { + return .expired + } + + if sensorState == .starting || sensorState == .notYetStarted { + return .warmup(progress: age / wear, remaining: wear - age) + } + + // If we've never received a successful reading yet, treat this as an + // initial grace period rather than signal loss, to avoid false positives + // immediately after pairing/activation. + if let latestReadingAt, now.timeIntervalSince(latestReadingAt) > signalLostThreshold { + return .signalLost(since: latestReadingAt) + } + + return .active(remaining: wear - age, total: wear) + } +} diff --git a/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift new file mode 100644 index 0000000..099e8ce --- /dev/null +++ b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift @@ -0,0 +1,38 @@ +// +// LibreTransmitterManagerV3+Alerts.swift +// LibreTransmitter +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +extension LibreTransmitterManagerV3 { + /// Diffs the currently-firing lifecycle alert conditions against the previous + /// evaluation and issues/retracts LoopKit Alerts for the delta. + func evaluateAlerts() { + let shouldFire = LibreAlertCondition.currentlyFiring(for: sensorLifecycle) + + guard shouldFire != firingAlertConditions else { + return + } + + let delegate = cgmManagerDelegate + let managerIdentifier = Self.pluginIdentifier + let newlyFiring = shouldFire.subtracting(firingAlertConditions) + let noLongerFiring = firingAlertConditions.subtracting(shouldFire) + + delegateQueue.async { + for condition in newlyFiring { + delegate?.issueAlert(condition.alert(managerIdentifier: managerIdentifier)) + } + for condition in noLongerFiring { + delegate?.retractAlert(identifier: condition.identifier(managerIdentifier: managerIdentifier)) + } + } + + firingAlertConditions = shouldFire + } +} diff --git a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift index 257f315..e56c7e6 100644 --- a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift +++ b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift @@ -65,13 +65,6 @@ extension LibreTransmitterManagerV3 { return } - if sensor.maxAge > 0 { - let minutesLeft = Double(sensor.maxAge - bleData.age) - NotificationHelper.sendSensorExpireAlertIfNeeded(minutesLeft: minutesLeft) - - } - - verifySensorChange(for: sensor.uuid, activatedAt: Date() - TimeInterval(minutes: Double(bleData.age))) diff --git a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift index 6c5d7f6..3edef2a 100644 --- a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift +++ b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift @@ -13,7 +13,6 @@ import LoopKit extension LibreTransmitterManagerV3 { public func noLibreTransmitterSelected() { - NotificationHelper.sendNoTransmitterSelectedNotification() } public func libreTransmitterDidUpdate(with sensorData: SensorData, and Device: LibreTransmitterMetadata) { @@ -21,7 +20,6 @@ extension LibreTransmitterManagerV3 { self.logger.debug("got sensordata: \(String(describing: sensorData)), bytescount: \( sensorData.bytes.count), bytes: \(sensorData.bytes)") var sensorData = sensorData - NotificationHelper.sendLowBatteryNotificationIfNeeded(device: Device) self.setObservables(sensorData: nil, bleData: nil, metaData: Device) if !sensorData.isLikelyLibre1FRAM { @@ -33,6 +31,7 @@ extension LibreTransmitterManagerV3 { } } else { logger.debug("Sensor type was incorrect, and no decryption of sensor was possible") + self.lastFault = .encryptedOrUnsupported self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.encryptedSensor)) return } @@ -44,8 +43,7 @@ extension LibreTransmitterManagerV3 { tryPersistSensorData(with: sensorData) - NotificationHelper.sendInvalidSensorNotificationIfNeeded(sensorData: sensorData) - NotificationHelper.sendInvalidChecksumIfDeveloper(sensorData) + self.lastKnownSensorState = sensorData.state guard sensorData.hasValidCRCs else { self.delegateQueue.async { @@ -56,20 +54,24 @@ extension LibreTransmitterManagerV3 { return } - NotificationHelper.sendSensorExpireAlertIfNeeded(sensorData: sensorData) - guard sensorData.state == .ready || sensorData.state == .starting else { logger.debug("got sensordata with valid crcs, but sensor is either expired or failed") + if sensorData.state == .failure || sensorData.state == .shutdown { + self.lastFault = .sensorFailure + } self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.expiredSensor)) } return } + // sensor is reporting a healthy state again, clear any previously recorded fault + self.lastFault = nil + logger.debug("got sensordata with valid crcs, sensor was ready") // self.lastValidSensorData = sensorData - + verifySensorChange(for: sensorData.uuid, activatedAt: Date() - TimeInterval(minutes: Double(sensorData.minutesSinceStart))) @@ -203,14 +205,12 @@ extension LibreTransmitterManagerV3 { do { try KeychainManager.standard.setLibreNativeCalibrationData(calibrationparams) } catch { - NotificationHelper.sendCalibrationNotification(.invalidCalibrationData) callback(.invalidCalibrationData, nil) return } // here we assume success, data is not changed, // and we trust that the remote endpoint returns correct data for the sensor - NotificationHelper.sendCalibrationNotification(.success) callback(nil, self?.readingToGlucose(data, calibration: calibrationparams)) } } @@ -250,10 +250,9 @@ extension LibreTransmitterManagerV3 { case .newSensor: //we can't be sure of the activation datetime for the new sensor here logger.debug("New libresensor detected") - NotificationHelper.sendSensorChangeNotificationIfNeeded() case .noSensor: logger.debug("No libresensor detected") - NotificationHelper.sendSensorNotDetectedNotificationIfNeeded(noSensor: true) + self.lastFault = .noSensorFound default: // we don't care about the rest! break diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index 93973a3..3974424 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -35,7 +35,35 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { } public var cgmManagerStatus: CGMManagerStatus { - CGMManagerStatus(hasValidSensorSession: hasValidSensorSession, device: nil) + CGMManagerStatus(hasValidSensorSession: hasValidSensorSession, lastCommunicationDate: latestReadingTimestamp, device: nil) + } + + /// Tracks which `LibreAlertCondition`s were issued as of the last `evaluateAlerts()` call, + /// so it can diff against the currently-firing set and retract anything that's resolved. + var firingAlertConditions: Set = [] + + /// Timestamp of the most recent successful glucose reading, from either data path. + /// Used to detect signal loss (absence of readings) and to populate `CGMManagerStatus.lastCommunicationDate`. + var latestReadingTimestamp: Date? + + /// The most recently observed hard sensor fault, normalized across both data paths. + /// Cleared once the sensor recovers to a normal reporting state. + var lastFault: LibreSensorLifecycle.FaultKind? + + /// The most recently reported `SensorState` byte from a classic (FRAM-reading) transmitter. + /// Direct-BLE Libre2 has no equivalent signal and leaves this `nil`. + var lastKnownSensorState: SensorState? + + public var sensorLifecycle: LibreSensorLifecycle { + LibreSensorLifecycle.compute( + sensorPaired: hasValidSensorSession, + activatedAt: sensorInfoObservable.activatedAt, + expiresAt: sensorInfoObservable.expiresAt, + latestReadingAt: latestReadingTimestamp, + sensorMaxMinutesWearTime: sensorInfoObservable.sensorMaxMinutesWearTime, + sensorState: lastKnownSensorState, + lastFault: lastFault + ) } public var glucoseDisplay: GlucoseDisplayable? @@ -167,13 +195,17 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { public func fetchNewDataIfNeeded(_ completion: @escaping (CGMReadingResult) -> Void) { logger.debug("fetchNewDataIfNeeded called but we don't continue") + // Real data delivery happens via the delegate callbacks (libreTransmitterDidUpdate / + // libreSensorDidUpdate), not through this polling entry point. However, this is the only + // place we can detect "silence" (no callback at all), which is what signal-loss means - + // so use it as a periodic safety net for evaluating lifecycle alerts. + evaluateAlerts() + completion(.noData) } public var lastConnected: Date? - public internal(set) var alarmStatus = AlarmStatus() - internal var latestPrediction: LibreGlucose? public var latestBackfill: LibreGlucose? { @@ -186,26 +218,8 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { let oldValue = latestBackfill defer { - logger.debug("sending glucose notification") - NotificationHelper.sendGlucoseNotificationIfNeeded(glucose: newValue, - oldValue: oldValue, - trend: trend, - battery: proxy?.metadata?.batteryString ?? "n/a", - glucoseFormatter: alertsUnitPreference.formatter) - - // once we have a new glucose value, we can update the isalarming property - if let activeAlarms = UserDefaults.standard.glucoseSchedules?.getActiveAlarms(newValue.glucoseDouble) { - DispatchQueue.main.async { - self.alarmStatus.isAlarming = ([.high, .low].contains(activeAlarms)) - self.alarmStatus.glucoseScheduleAlarmResult = activeAlarms - } - } else { - DispatchQueue.main.async { - self.alarmStatus.isAlarming = false - self.alarmStatus.glucoseScheduleAlarmResult = .none - } - } - + latestReadingTimestamp = newValue.startDate + evaluateAlerts() } logger.debug("latestBackfill set, newvalue is \(newValue.glucose)") @@ -254,7 +268,6 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { lastConnected = nil logger.debug("LibreTransmitterManager will be created now") - NotificationHelper.requestNotificationPermissionsIfNeeded() if isDeviceSelected { establishProxy() diff --git a/LibreTransmitter/NotificationHelper.swift b/LibreTransmitter/NotificationHelper.swift deleted file mode 100644 index 12d9f90..0000000 --- a/LibreTransmitter/NotificationHelper.swift +++ /dev/null @@ -1,491 +0,0 @@ -// -// NotificationHelper.swift -// MiaomiaoClient -// -// Created by LoopKit Authors on 30/05/2019. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import AudioToolbox -import Foundation -import HealthKit -import LoopKit -import UserNotifications -import os.log - -private var logger = Logger(forType: "NotificationHelper") -// MARK: - Notification Utilities -public enum NotificationHelper { - - private enum Identifiers: String { - case glucocoseNotifications = "com.loopkit.libremiaomiao.glucose-notification" - case noSensorDetected = "com.loopkit.libremiaomiao.nosensordetected-notification" - case tryAgainLater = "com.loopkit.libremiaomiao.glucoseNotAvailableTryAgainLater-notification" - case sensorChange = "com.loopkit.libremiaomiao.sensorchange-notification" - case invalidSensor = "com.loopkit.libremiaomiao.invalidsensor-notification" - case lowBattery = "com.loopkit.libremiaomiao.lowbattery-notification" - case sensorExpire = "com.loopkit.libremiaomiao.SensorExpire-notification" - case noBridgeSelected = "com.loopkit.libremiaomiao.noBridgeSelected-notification" - case invalidChecksum = "com.loopkit.libremiaomiao.invalidChecksum-notification" - 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 - - - - private static func vibrate(times: Int=3) { - guard times >= 0 else { - return - } - - - AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_Vibrate) { - vibrate(times: times - 1) - } - } - - 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 - - UNUserNotificationCenter.current().getNotificationSettings { settings in - criticalAlarmsEnabled = settings.criticalAlertSetting == .enabled - logPermissions(settings) - - if shouldRequestCriticalPermissions || NotificationHelperOverride.shouldOverrideRequestCriticalPermissions { - requestCriticalNotificationPermissions() - } - - } - } - - private static func ensureCanSendNotification(_ completion: @escaping () -> Void ) { - UNUserNotificationCenter.current().getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { - logger.debug("\(#function) failed, authorization denied") - return - } - logger.debug("\(#function) sending notification was allowed") - - completion() - } - } - - private static func addRequest(identifier: Identifiers, content: UNMutableNotificationContent, deleteOld: Bool = false, isCritical: Bool = false) { - let center = UNUserNotificationCenter.current() - - if isCritical && Self.criticalAlarmsEnabled { - logger.debug("\(#function) critical alarm created") - content.interruptionLevel = .critical - - let criticalVolume = UserDefaults.standard.mmCriticalAlarmsVolume < 60 ? 60 : UserDefaults.standard.mmCriticalAlarmsVolume - logger.debug("\(#function) setting criticalVolume to \(criticalVolume)%") - content.sound = .defaultCriticalSound(withAudioVolume: Float(criticalVolume / 100)) - } else { - logger.debug("\(#function) timesensitive alarm created") - content.interruptionLevel = .timeSensitive - } - - let request = UNNotificationRequest(identifier: identifier.rawValue, content: content, trigger: nil) - - if deleteOld { - // Required since ios12+ have started to cache/group notifications - center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue]) - center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue]) - } - - center.add(request) { error in - if let error { - logger.debug("\(#function) unable to addNotificationRequest: \(error.localizedDescription)") - return - } - - logger.debug("\(#function) sending \(identifier.rawValue) notification") - } - } - -} - -// MARK: Sensor related notification sendouts -public extension NotificationHelper { - static func sendLibre2DirectFinishedSetupNotifcation() { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "Libre 2 Direct Setup Complete" - content.body = "Establishing initial connection can take up to 4 minutes. Keep your phone unlocked and Loop in the foreground while connecting" - - addRequest(identifier: .libre2directFinishedSetup, content: content) - } - } - - static func sendSensorNotDetectedNotificationIfNeeded(noSensor: Bool) { - guard UserDefaults.standard.mmAlertNoSensorDetected && noSensor else { - logger.debug("\(#function) Not sending noSensorDetected notification") - return - } - - sendSensorNotDetectedNotification() - } - - private static func sendSensorNotDetectedNotification() { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "No Sensor Detected" - content.body = "This might be an intermittent problem, but please check that your transmitter is tightly secured over your sensor" - - addRequest(identifier: .noSensorDetected, content: content) - } - } - - static func sendSensorChangeNotificationIfNeeded() { - guard UserDefaults.standard.mmAlertNewSensorDetected else { - logger.debug("\(#function) not sending sendSensorChange notification ") - return - } - sendSensorChangeNotification() - } - - static func sendSensorChangeNotification() { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "New Sensor Detected" - content.body = "Please wait up to 30 minutes before glucose readings are available!" - - addRequest(identifier: .sensorChange, content: content) - // content.sound = UNNotificationSound. - - } - } - - static func sendSensorTryAgainLaterNotification() { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "Invalid Glucose sample detected, try again later" - content.body = "Sensor might have temporarily stopped, fallen off or is too cold or too warm" - - addRequest(identifier: .tryAgainLater, content: content) - // content.sound = UNNotificationSound. - - } - } - - static func sendInvalidSensorNotificationIfNeeded(sensorData: SensorData) { - let isValid = sensorData.isLikelyLibre1FRAM && (sensorData.state == .starting || sensorData.state == .ready) - - guard UserDefaults.standard.mmAlertInvalidSensorDetected && !isValid else { - logger.debug("\(#function) not sending invalidSensorDetected notification") - return - } - - sendInvalidSensorNotification(sensorData: sensorData) - } - - enum CalibrationMessage: String { - case starting = "Calibrating sensor, please stand by!" - case noCalibration = "Could not calibrate sensor, check libreoopweb permissions and internet connection" - case invalidCalibrationData = "Could not calibrate sensor, invalid calibrationdata" - case success = "Success!" - } - - static func sendCalibrationNotification(_ calibrationMessage: CalibrationMessage) { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.sound = .default - content.title = "Extracting calibrationdata from sensor" - content.body = calibrationMessage.rawValue - - addRequest(identifier: .calibrationOngoing, - content: content, - deleteOld: true) - } - } - - static func sendInvalidSensorNotification(sensorData: SensorData) { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "Invalid Sensor Detected" - - if !sensorData.isLikelyLibre1FRAM { - content.body = "Detected sensor seems not to be a libre 1 sensor!" - } else if !(sensorData.state == .starting || sensorData.state == .ready) { - content.body = "Detected sensor is invalid: \(sensorData.state.description)" - } - - content.sound = .default - - addRequest(identifier: .invalidSensor, content: content) - } - } - - private static var lastSensorExpireAlert: Date? - - static func sendSensorExpireAlertIfNeeded(minutesLeft: Double) { - guard UserDefaults.standard.mmAlertWillSoonExpire else { - logger.debug("\(#function) mmAlertWillSoonExpire toggle was not enabled, not sending expiresoon alarm") - return - } - - guard TimeInterval(minutes: minutesLeft) < TimeInterval(hours: 24) else { - logger.debug("\(#function) Sensor time left was more than 24 hours, not sending notification: \(minutesLeft.twoDecimals) minutes") - return - } - - let now = Date() - // only once per 6 hours - let min45 = 60.0 * 60 * 6 - - if let earlier = lastSensorExpireAlert { - if earlier.addingTimeInterval(min45) < now { - sendSensorExpireAlert(minutesLeft: minutesLeft) - lastSensorExpireAlert = now - } else { - logger.debug("\(#function) Sensor is soon expiring, but lastSensorExpireAlert was sent less than 6 hours ago, so aborting") - } - } else { - sendSensorExpireAlert(minutesLeft: minutesLeft) - lastSensorExpireAlert = now - } - } - - static func sendSensorExpireAlertIfNeeded(sensorData: SensorData) { - sendSensorExpireAlertIfNeeded(minutesLeft: Double(sensorData.minutesLeft)) - } - - private static func sendSensorExpireAlert(minutesLeft: Double) { - ensureCanSendNotification { - - let hours = minutesLeft == 0 ? 0 : round(minutesLeft/60) - - let dynamicText = hours <= 1 ? "minutes: \(minutesLeft.twoDecimals)" : "hours: \(hours.twoDecimals)" - - let content = UNMutableNotificationContent() - content.title = "Sensor Ending Soon" - content.body = "Current Sensor is Ending soon! Sensor Life left in \(dynamicText)" - - addRequest(identifier: .sensorExpire, content: content, deleteOld: true, isCritical: true) - } - } -} - -// MARK: - Notification sendout -public extension NotificationHelper { - - - static func sendNoTransmitterSelectedNotification() { - ensureCanSendNotification { - logger.debug("\(#function) sending NoTransmitterSelectedNotification") - - let content = UNMutableNotificationContent() - content.title = "No Libre Transmitter Selected" - content.body = "Delete CGMManager and start anew. Your libreoopweb credentials will be preserved" - - addRequest(identifier: .noBridgeSelected, content: content) - } - } - - static func sendInvalidChecksumIfDeveloper(_ sensorData: SensorData) { - guard UserDefaults.standard.dangerModeActivated else { - return - } - - if sensorData.hasValidCRCs { - return - } - - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "Invalid libre checksum" - content.body = "Libre sensor was incorrectly read, CRCs were not valid" - - addRequest(identifier: .invalidChecksum, content: content) - } - } - - private static var glucoseNotifyCalledCount = 0 - - static func sendGlucoseNotificationIfNeeded(glucose: LibreGlucose, oldValue: LibreGlucose?, trend: GlucoseTrend?, battery: String?, glucoseFormatter: QuantityFormatter) { - glucoseNotifyCalledCount &+= 1 - - let shouldSendGlucoseAlternatingTimes = glucoseNotifyCalledCount != 0 && UserDefaults.standard.mmNotifyEveryXTimes != 0 - - let shouldSend = UserDefaults.standard.mmAlwaysDisplayGlucose || - (shouldSendGlucoseAlternatingTimes && glucoseNotifyCalledCount % UserDefaults.standard.mmNotifyEveryXTimes == 0) - - let schedules = UserDefaults.standard.glucoseSchedules - - let alarm = schedules?.getActiveAlarms(glucose.glucoseDouble) ?? .none - let isSnoozed = GlucoseScheduleList.isSnoozed() - - let shouldShowPhoneBattery = UserDefaults.standard.mmShowPhoneBattery - let transmitterBattery = UserDefaults.standard.mmShowTransmitterBattery && battery != nil ? battery : nil - - logger.debug("\(#function) glucose alarmtype is \(String(describing: alarm))") - // We always send glucose notifications when alarm is active, - // even if glucose notifications are disabled in the UI - - if shouldSend || alarm.isAlarming() { - sendGlucoseNotification(glucose: glucose, oldValue: oldValue, - glucoseFormatter: glucoseFormatter, - alarm: alarm, isSnoozed: isSnoozed, - trend: trend, showPhoneBattery: shouldShowPhoneBattery, - transmitterBattery: transmitterBattery) - } else { - logger.debug("\(#function) not sending glucose, shouldSend and alarmIsActive was false") - return - } - } - - private static func sendGlucoseNotification(glucose: LibreGlucose, oldValue: LibreGlucose?, - glucoseFormatter: QuantityFormatter, - alarm: GlucoseScheduleAlarmResult = .none, - isSnoozed: Bool = false, - trend: GlucoseTrend?, - showPhoneBattery: Bool = false, - transmitterBattery: String?) { - let content = UNMutableNotificationContent() - let glucoseDesc = glucoseFormatter.string(from: glucose.quantity)! - var titles = [String]() - var body = [String]() - var body2 = [String]() - - var isCritical = false - switch alarm { - case .none: - titles.append("Glucose") - case .low: - titles.append("LOWALERT!") - isCritical = true - case .high: - titles.append("HIGHALERT!") - isCritical = true - } - - if isSnoozed { - titles.append("(Snoozed)") - } else if alarm.isAlarming() { - content.sound = .default - - if Features.glucoseAlarmsAlsoCauseVibration { - vibrate() - } - - } - titles.append(glucoseDesc) - - body.append("Glucose: \(glucoseDesc)") - - if let oldValue { - let diff = glucose.glucoseDouble - oldValue.glucoseDouble - if diff >= 0 { - body.append("+") - } - body.append( glucoseFormatter.string(from: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: diff))!) - } - - if let trend = trend?.localizedDescription { - body.append("\(trend)") - } - - if showPhoneBattery { - if !UIDevice.current.isBatteryMonitoringEnabled { - UIDevice.current.isBatteryMonitoringEnabled = true - } - - let battery = Double(UIDevice.current.batteryLevel * 100 ).roundTo(places: 1) - body2.append("Phone: \(battery)%") - } - - if let transmitterBattery { - body2.append("Transmitter: \(transmitterBattery)") - } - - // these are texts that naturally fit on their own line in the body - var body2s = "" - if !body2.isEmpty { - body2s = "\n" + body2.joined(separator: "\n") - } - - content.title = titles.joined(separator: " ") - content.body = body.joined(separator: ", ") + body2s - addRequest(identifier: .glucocoseNotifications, - content: content, - deleteOld: true, isCritical: isCritical && !isSnoozed) - } - - private static var lastBatteryWarning: Date? - - static func sendLowBatteryNotificationIfNeeded(device: LibreTransmitterMetadata) { - guard UserDefaults.standard.mmAlertLowBatteryWarning else { - logger.debug("\(#function) mmAlertLowBatteryWarning toggle was not enabled, not sending low notification") - return - } - - if let battery = device.battery, battery > 20 { - logger.debug("\(#function) device battery is \(battery), not sending low notification") - return - - } - - let now = Date() - // only once per mins minute - let mins = 60.0 * 120 - if let earlierplus = lastBatteryWarning?.addingTimeInterval(mins) { - if earlierplus < now { - sendLowBatteryNotification(batteryPercentage: device.batteryString, - deviceName: device.name) - lastBatteryWarning = now - } else { - logger.debug("\(#function) Device battery is running low, but lastBatteryWarning Notification was sent less than 45 minutes ago, aborting. earlierplus: \(earlierplus), now: \(now)") - } - } else { - sendLowBatteryNotification(batteryPercentage: device.batteryString, - deviceName: device.name) - lastBatteryWarning = now - } - } - - private static func sendLowBatteryNotification(batteryPercentage: String, deviceName: String) { - ensureCanSendNotification { - let content = UNMutableNotificationContent() - content.title = "Low Battery" - content.body = "Battery is running low (\(batteryPercentage)), consider charging your \(deviceName) device as soon as possible" - content.sound = .default - - addRequest(identifier: .lowBattery, content: content) - } - } - -} diff --git a/LibreTransmitter/NotificationHelperOverride.swift b/LibreTransmitter/NotificationHelperOverride.swift deleted file mode 100644 index 80daa2c..0000000 --- a/LibreTransmitter/NotificationHelperOverride.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// NotificationHelperOverride.swift -// LibreTransmitter -// -// Created by Bjørn Inge Berg on 16/01/2023. -// Copyright © 2023 Mark Wilson. All rights reserved. -// - -import Foundation -enum NotificationHelperOverride { - static var shouldOverrideRequestCriticalPermissions : Bool { - // if you want LibreTransmitter to try upgrading to critical notifications, change this - false - } -} diff --git a/LibreTransmitter/Observables/AlarmStatus.swift b/LibreTransmitter/Observables/AlarmStatus.swift deleted file mode 100644 index 92addf1..0000000 --- a/LibreTransmitter/Observables/AlarmStatus.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// AlarmStatus.swift -// LibreTransmitter -// -// Created by LoopKit Authors on 09/07/2021. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import Foundation -public class AlarmStatus: ObservableObject, Equatable, Hashable { - @Published public var isAlarming = false - @Published public var glucoseScheduleAlarmResult = GlucoseScheduleAlarmResult.none - - public static func ==(lhs: AlarmStatus, rhs: AlarmStatus) -> Bool { - lhs.isAlarming == rhs.isAlarming && lhs.glucoseScheduleAlarmResult == rhs.glucoseScheduleAlarmResult - } - - static public func createNew() -> AlarmStatus { - AlarmStatus() - } -} diff --git a/LibreTransmitterUI/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index aa50375..a70618a 100644 --- a/LibreTransmitterUI/LibreTransmitterManager+UI.swift +++ b/LibreTransmitterUI/LibreTransmitterManager+UI.swift @@ -18,10 +18,26 @@ struct LibreLifecycleProgress: DeviceLifecycleProgress { var progressState: LoopKit.DeviceLifecycleProgressState } +struct LibreStatusHighlight: DeviceStatusHighlight { + var localizedMessage: String + var imageName: String + var state: DeviceStatusHighlightState +} + +struct LibreStatusBadge: DeviceStatusBadge { + var image: UIImage? + var state: DeviceStatusBadgeState +} + extension LibreTransmitterManagerV3: CGMManagerUI { public var cgmStatusBadge: DeviceStatusBadge? { - nil + switch sensorLifecycle { + case .expired: + return LibreStatusBadge(image: UIImage(systemName: "exclamationmark.triangle.fill"), state: .critical) + default: + return nil + } } public static func setupViewController(bluetoothProvider: BluetoothProvider, displayGlucosePreference: DisplayGlucosePreference, colorPalette: LoopUIColorPalette, allowDebugFeatures: Bool, prefersToSkipUserInteraction: Bool) -> SetupUIResult @@ -49,7 +65,6 @@ extension LibreTransmitterManagerV3: CGMManagerUI { notifyDelete: wantToTerminateNotifier, notifyReset: wantToResetCGMManagerNotifier, notifyReconnect:wantToRestablishConnectionNotifier, - alarmStatus: self.alarmStatus, pairingService: self.pairingService, bluetoothSearcher: self.bluetoothSearcher ) @@ -99,7 +114,30 @@ extension LibreTransmitterManagerV3: CGMManagerUI { } public var cgmStatusHighlight: DeviceStatusHighlight? { - nil + switch sensorLifecycle { + case .warmup: + return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nWarmup", comment: "Status highlight message for sensor warmup"), imageName: "clock", state: .normalCGM) + case let .active(remaining, _): + // Matches cgmLifecycleProgress's own visibility/severity thresholds + // (see there for why 48h/24h): give the user a heads-up banner well + // before the progress bar turns warning-colored, so there's time to + // order a replacement before it becomes urgent. + guard remaining < TimeInterval(hours: 48) else { + return nil + } + let state: DeviceStatusHighlightState = remaining < TimeInterval(hours: 24) ? .warning : .normalCGM + return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpiring Soon", comment: "Status highlight message for a sensor nearing expiry"), imageName: "clock", state: state) + case .expired: + return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpired", comment: "Status highlight message for expired sensor"), imageName: "clock", state: .normalCGM) + case .signalLost: + return LibreStatusHighlight(localizedMessage: LocalizedString("Signal\nLoss", comment: "Status highlight message for signal loss"), imageName: "exclamationmark.circle.fill", state: .warning) + case .failed: + return LibreStatusHighlight(localizedMessage: LocalizedString("Replace\nSensor", comment: "Status highlight message for a failed sensor"), imageName: "exclamationmark.circle.fill", state: .critical) + case .unactivated: + return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nNot Detected", comment: "Status highlight message for a sensor that is not detected"), imageName: "exclamationmark.circle.fill", state: .critical) + case .noSensor: + return nil + } } public var cgmLifecycleProgress: DeviceLifecycleProgress? { @@ -109,20 +147,25 @@ extension LibreTransmitterManagerV3: CGMManagerUI { // We could show 0 here, but UX-wise it's probably wiser to not do so return nil } - - let minutesLeft = Double(self.sensorInfoObservable.sensorMinutesLeft) - - // This matches the manufacturere's app where it displays a notification when sensor has less than 3 days left - if TimeInterval(minutes: minutesLeft) < TimeInterval(hours: 24*3) { - let progress = self.sensorInfoObservable.calculateProgress() - if TimeInterval(minutes: minutesLeft) < TimeInterval(hours: 24) { - return LibreLifecycleProgress(percentComplete: progress, progressState: .warning) + + switch sensorLifecycle { + case let .warmup(progress, _): + return LibreLifecycleProgress(percentComplete: progress, progressState: .warning) + case let .active(remaining, total): + // Mirrors G7SensorKit's cgmLifecycleProgress exactly: the bar only + // appears in the final 48h, .warning inside the final 24h, + // .normalCGM for the 24-48h stretch before that. + guard remaining < TimeInterval(hours: 48) else { + return nil } - return LibreLifecycleProgress(percentComplete: progress, progressState: .normalCGM) + let percent = 1 - (remaining / total) + let state: DeviceLifecycleProgressState = remaining < TimeInterval(hours: 24) ? .warning : .normalCGM + return LibreLifecycleProgress(percentComplete: percent, progressState: state) + case .expired: + return LibreLifecycleProgress(percentComplete: 1, progressState: .critical) + case .signalLost, .failed, .unactivated, .noSensor: + return nil } - - return nil - } } diff --git a/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift b/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift deleted file mode 100644 index 0467ba4..0000000 --- a/LibreTransmitterUI/Views/Settings/AlarmSettings/AlarmSettingsView.swift +++ /dev/null @@ -1,380 +0,0 @@ -// -// AlarmSettingsView.swift -// LibreTransmitterUI -// -// Created by LoopKit Authors on 11/05/2021. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import HealthKit - -private func systemImage(_ name:String) -> some View { - Image(systemName: name) - .resizable() - .interpolation(.high) - .scaledToFit() - .frame(width: 40) -} - -class AlarmScheduleState: ObservableObject, Identifiable, Hashable { - - var id = UUID() - - @Published var lowmgdl: Double = 72 - @Published var highmgdl: Double = 180 - @Published var enabled: Bool? = false - - @Published var alarmDateComponents: AlarmTimeCellExternalState = AlarmTimeCellExternalState() - - public func setLowAlarm(forUnit unit: HKUnit, lowAlarm: Double) { - - if unit == HKUnit.millimolesPerLiter { - self.lowmgdl = lowAlarm * 18 - return - } - - self.lowmgdl = lowAlarm - } - - public func getLowAlarm(forUnit unit: HKUnit) -> Double { - - if unit == HKUnit.millimolesPerLiter { - return (lowmgdl / 18).roundTo(places: 1) - } - - return lowmgdl - - } - - public func setHighAlarm(forUnit unit: HKUnit, highAlarm: Double) { - - if unit == HKUnit.millimolesPerLiter { - self.highmgdl = highAlarm * 18 - return - } - - self.highmgdl = highAlarm - } - - public func getHighAlarm(forUnit unit: HKUnit) -> Double { - - if unit == HKUnit.millimolesPerLiter { - return (highmgdl / 18).roundTo(places: 1) - } - return highmgdl - - } - -} - -class AlarmSettingsState: ObservableObject { - @Published var schedules: [AlarmScheduleState] = [] - - static private func setDateComponentState(_ state: AlarmScheduleState) { - if state.alarmDateComponents.startComponents == nil { - state.alarmDateComponents.startComponents = DateComponents(hour: 0, minute: 0) - } - - if state.alarmDateComponents.endComponents == nil { - state.alarmDateComponents.endComponents = DateComponents(hour: 0, minute: 0) - } - - let from = state.alarmDateComponents.startComponents!.ToTimeString() - let to = state.alarmDateComponents.endComponents!.ToTimeString() - - state.alarmDateComponents.componentsAsText = "\(from) - \(to)" - - } - - // this is just to be able to use old serialized schedules from uikit version - // i.e. we want to be drop in compatible - static func loadState() -> AlarmSettingsState { - - guard let storedState = UserDefaults.standard.glucoseSchedules, storedState.schedules.count > 0 else { - print("stored state for alarms was empty") - let newState = AlarmSettingsState() - for _ in (0.. StatusMessage? { - let legacyState = GlucoseScheduleList() - for newStateSchedule in self.schedules { - let glucoseSchedule = GlucoseSchedule() - glucoseSchedule.enabled = newStateSchedule.enabled - // view is using wrapper binding to store these values so should be safe - glucoseSchedule.lowAlarm = newStateSchedule.lowmgdl - glucoseSchedule.highAlarm = newStateSchedule.highmgdl - glucoseSchedule.from = newStateSchedule.alarmDateComponents.startComponents - glucoseSchedule.to = newStateSchedule.alarmDateComponents.endComponents - - legacyState.schedules.append(glucoseSchedule) - - } - let result = legacyState.validateGlucoseSchedules() - switch result { - case .success: - - print("glucose schedule valid: \(String(describing: legacyState))") - UserDefaults.standard.glucoseSchedules = legacyState - case .error(let description): - print("Could not save glucose schedules, validation failed: \(description)") - return StatusMessage(title: "Error", message: description) - } - - return nil - - } -} - -struct AlarmDateRow: View { - @ObservedObject var schedule: AlarmScheduleState - @State var tag: Int - @Binding var subviewSelection: Int? - - var body: some View { - - HStack(alignment: .center) { - NavigationLink(destination: CustomDataPickerView().environmentObject(schedule.alarmDateComponents), - tag: tag, - selection: $subviewSelection) { - Group { - systemImage("clock.arrow.2.circlepath") - .frame(maxWidth: 50, alignment: .leading) - TextField("Active from - to ", text: Binding(get: { "\(schedule.alarmDateComponents.componentsAsText)" }, - set: { schedule.alarmDateComponents.componentsAsText = $0 })) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .disableAutocorrection(true) - .keyboardType(.decimalPad) - .border(Color(UIColor.separator)) - .disabled(true) - .frame(minWidth: 130, idealWidth: 130, maxWidth: .infinity, alignment: .center) - }.onTapGesture { - print("cheduleActivationRow tapped") - subviewSelection = tag - self.hideKeyboardPreIos16() - - } - - } - - Toggle("", isOn: Binding( - get: { - schedule.enabled == true - }, - set: { - if $0 != schedule.enabled { - schedule.enabled = $0 - } - } - )) - .frame(maxWidth: 50, alignment: .trailing) - - } - } -} - -struct AlarmLowRow: View { - @ObservedObject var schedule: AlarmScheduleState - var glucoseUnit: HKUnit - var glucoseUnitDesc: String - - var errorReporter: FormErrorState - - @FocusState private var isInputFocused: Bool - var body: some View { - HStack(alignment: .center) { - - systemImage("arrowtriangle.down.circle") - .frame(maxWidth: 50, alignment: .leading) - Text(LocalizedString("Low", comment: "Text describing Low glucose label in alarmsettingsview")) - .frame(maxWidth: 100, alignment: .leading) - .onTapGesture { - isInputFocused.toggle() - } - Spacer() - - NumericTextField(description: "glucose", showDescription: false, - numericValue: Binding( - get: { - schedule.getLowAlarm(forUnit: glucoseUnit) - - }, - set: { - schedule.setLowAlarm(forUnit: glucoseUnit, lowAlarm: $0) - }), formErrorState: errorReporter) - .focused($isInputFocused) - - Text("\(glucoseUnitDesc)") - .font(.footnote) - .frame(maxWidth: 100, alignment: .trailing) - - } - .onTapGesture { - self.hideKeyboardPreIos16() - } - } -} - -struct AlarmHighRow: View { - @ObservedObject var schedule: AlarmScheduleState - var glucoseUnit: HKUnit - var glucoseUnitDesc: String - - var errorReporter: FormErrorState - @FocusState private var isInputFocused: Bool - var body: some View { - HStack(alignment: .center) { - - systemImage( "arrowtriangle.up.circle") - .frame(maxWidth: 50, alignment: .leading) - Text(LocalizedString("High", comment: "Text describing High glucose label in alarmsettingsview")) - .frame(maxWidth: 100, alignment: .leading) - .onTapGesture { - isInputFocused.toggle() - } - Spacer() - - NumericTextField(description: "glucose", showDescription: false, - numericValue: Binding( - get: { schedule.getHighAlarm(forUnit: glucoseUnit) }, - set: { - schedule.setHighAlarm(forUnit: glucoseUnit, highAlarm: $0) - - }), formErrorState: errorReporter) - .focused($isInputFocused) - Text("\(glucoseUnitDesc)") - .font(.footnote) - .frame(maxWidth: 100, alignment: .trailing) - - } - .onTapGesture { - self.hideKeyboardPreIos16() - } - } -} - -struct AlarmSettingsView: View { - - private(set) var glucoseUnit: HKUnit - - var glucoseUnitDesc: String { - // "mmol/L" - glucoseUnit.localizedShortUnitString - } - - @State private var presentableStatus: StatusMessage? - @StateObject var alarmState = AlarmSettingsState.loadState() - @State private var subviewSelection: Int? - - @State private var authSuccess = false - - // Set this to true to require system authentication - // for accessing the alarm section - @State private var requiresAuthentication = Features.alarmSettingsViewRequiresAuthentication - - var body: some View { - erasedWithKeyboardDismissal(list) - .alert(item: $presentableStatus) { status in - Alert(title: Text(status.title), message: Text(status.message), dismissButton: .default(Text("Got it!"))) - } - .navigationBarTitle("Alarm Settings") - .onAppear { - if requiresAuthentication && !authSuccess { - self.authenticate { success in - print("got authentication response: \(success)") - authSuccess = success - } - } - - } - .disabled(requiresAuthentication ? !authSuccess : false) - } - - func erasedWithKeyboardDismissal(_ view: any View) -> AnyView { - if #available(iOS 16.0, *) { - return AnyView(view.scrollDismissesKeyboard(.immediately)) - } - - return AnyView(view) - } - - @StateObject var errorReporter = FormErrorState() - - var list: some View { - - List { - 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) - - }.onTapGesture { - self.hideKeyboardPreIos16() - } - - } - - Section { - Button("Save") { - saveButtonAction() - }.buttonStyle(BlueButtonStyle()) - } - } - .listStyle(InsetGroupedListStyle()) - - } - - func saveButtonAction() { - print("tapped save schedules") - if errorReporter.hasAnyError { - presentableStatus = StatusMessage(title: "Error", message: "Some ui element was incorrectly specified") - return - } - if let error = alarmState.trySaveState() { - presentableStatus = error - } else { - presentableStatus = StatusMessage(title: "Success", message: "Schedules were saved successfully!") - } - - } - -} - -struct AlarmSettingsView_Previews: PreviewProvider { - static var previews: some View { - AlarmSettingsView(glucoseUnit: .millimolesPerLiter) - } -} 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/AlarmSettings/CustomDataPickerView.swift b/LibreTransmitterUI/Views/Settings/AlarmSettings/CustomDataPickerView.swift deleted file mode 100644 index 9827358..0000000 --- a/LibreTransmitterUI/Views/Settings/AlarmSettings/CustomDataPickerView.swift +++ /dev/null @@ -1,260 +0,0 @@ -// -// CustomDataPickerView.swift -// LibreTransmitterUI -// -// Created by LoopKit Authors on 28/04/2021. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import SwiftUI - -protocol CustomDataPickerDelegate: AnyObject { - func pickerDidPickValidRange() - -} - -class AlarmTimeCellExternalState: ObservableObject, Identifiable, Hashable { - - var id = UUID() - - @Published var start: Int = 0 - @Published var end: Int = 0 - - // These will be auto populøated - // when the start and end properties above change - @Published var startComponents: DateComponents? - @Published var endComponents: DateComponents? - - @Published var componentsAsText: String = "" - -} - -// handle parts of alarmsettingsview's state (=externalstate) -struct CustomDataPickerView: View { - private var startComponentTimes: [DateComponents] - private var endComponentTimes: [DateComponents] - - private var startTimes = [String]() - private var endTimes = [String]() - - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var externalState: AlarmTimeCellExternalState - - @State var externalStateCopy: AlarmTimeCellExternalState = AlarmTimeCellExternalState() - - public weak var delegate: CustomDataPickerDelegate? - - private func popView() { - self.presentationMode.wrappedValue.dismiss() - } - - static func defaultTimeArray() -> [DateComponents] { - var arr = [DateComponents]() - - for hr in 0...23 { - for min in 0 ..< 2 { - var components = DateComponents() - components.hour = hr - components.minute = min == 1 ? 30 : 0 - arr.append(components) - } - } - var components = DateComponents() - components.hour = 0 - components.minute = 0 - arr.append(components) - - return arr - } - - private func callDelegate() { - delegate?.pickerDidPickValidRange() - } - - private func verifyRange() { - - // This can be simplified but decided not to do so - // because the intention becomes more clear - - var isok: Bool - - if externalState.start == 0 || externalState.end == 0 { - isok = true - } else { - if externalState.start > externalState.end { - isok = false - } else if externalState.end < externalState.start { - isok = false - } else { - isok = true - } - } - - print("is ok? \(isok)") - if isok { - updateTextualState() - callDelegate() - popView() - - } else { - presentableStatus = .init(title: "Interval error", message: "Selected time interval was incorrectly specified") - } - - } - - var pickers: some View { - HStack { - Picker("", selection: $externalState.start, - content: { - ForEach(startTimes.indices, id: \.self) { i in - Text("\(startTimes[i])").tag(i) - } - } - ) - // .border(Color.green) - - .zIndex(10) - .frame(width: 100) - .clipped() - .labelsHidden() - - Text(LocalizedString("To ", comment: "Very short text describing separation between start and end datetimes")) - - Picker("", selection: $externalState.end, - content: { - ForEach(endTimes.indices, id: \.self) { i in - Text("\(endTimes[i])").tag(i) - } - } - ) - // .border(Color.red) - .zIndex(11) - .frame(width: 100) - .clipped() - .labelsHidden() - - // } - - .navigationBarBackButtonHidden(true) - .navigationBarItems( - leading: - Button("Cancel") { - print("cancel button pressed, restoring state...") - restoreAlarmExternalState() - popView() - - }.accentColor(.red), - trailing: - Button("Save") { - print("Save button pressed...") - verifyRange() - } - .disabled(saveButtonDisabled) - .accentColor(.red) - - ) - } - - } - - @State private var presentableStatus: StatusMessage? - @State private var saveButtonDisabled = true - - private func updateTextualState(_ shouldDelete: Bool = false) { - if shouldDelete { - externalState.componentsAsText = "" - return - } - if let p1 = externalState.startComponents?.ToTimeString(), let p2 = externalState.endComponents?.ToTimeString() { - externalState.componentsAsText = "\(p1) - \(p2)" - } - } - - var body: some View { - - pickers - .pickerStyle(InlinePickerStyle()) - .onChange(of: externalState.start, perform: { value in - print("selectedtart changed to \(value)") - externalState.startComponents = startComponentTimes[value] - saveButtonDisabled = false - - }) - .onChange(of: externalState.end, perform: { value in - print("selectedEnd changed to \(value)") - externalState.endComponents = endComponentTimes[value] - saveButtonDisabled = false - - }) - .onAppear { - // this could potentially fail with out of bounds but we trust our parent view! - externalState.startComponents = startComponentTimes[externalState.start] - externalState.endComponents = endComponentTimes[externalState.end] - updateTextualState() - - copyAlarmExternalState() - - } - .alert(item: $presentableStatus) { status in - Alert(title: Text(status.title), message: Text(status.message), dismissButton: .default(Text("Got it!"))) - } - - } - - // decided against uding nscoding with copy() here - private func copyAlarmExternalState() { - externalStateCopy = AlarmTimeCellExternalState() - /* - var id = UUID() - - @Published var start : Int = 0 - @Published var end : Int = 0 - - // These will be auto populøated - // when the start and end properties above change - @Published var startComponents : DateComponents? = nil - @Published var endComponents : DateComponents? = nil - - @Published var componentsAsText : String = ""**/ - externalStateCopy.id = externalState.id - externalStateCopy.start = externalState.start - externalStateCopy.end = externalState.end - externalStateCopy.startComponents = externalState.startComponents - externalStateCopy.endComponents = externalStateCopy.endComponents - externalStateCopy.componentsAsText = externalState.componentsAsText - - } - - private func restoreAlarmExternalState() { - externalState.id = externalStateCopy.id - externalState.start = externalStateCopy.start - externalState.end = externalStateCopy.end - externalState.startComponents = externalStateCopy.startComponents - externalStateCopy.endComponents = externalStateCopy.endComponents - externalState.componentsAsText = externalStateCopy.componentsAsText - - } - - init() { - startComponentTimes = Self.defaultTimeArray() - endComponentTimes = Self.defaultTimeArray() - - // string representations of the datecomponents arrays - - for component in startComponentTimes { - startTimes.append(component.ToTimeString(wantsAMPM: Date.LocaleWantsAMPM)) - } - - for component in endComponentTimes { - endTimes.append(component.ToTimeString(wantsAMPM: Date.LocaleWantsAMPM)) - - } - - } -} - -struct CustomDataPickerView_Previews: PreviewProvider { - static var previews: some View { - CustomDataPickerView().environmentObject(AlarmTimeCellExternalState()) - } -} diff --git a/LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift b/LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift deleted file mode 100644 index 33c73a6..0000000 --- a/LibreTransmitterUI/Views/Settings/NotificationSettingsView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// NotificationSettingsView.swift -// LibreTransmitterUI -// -// Created by LoopKit Authors on 27/05/2021. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import Combine -import LibreTransmitter -import HealthKit -import LoopKitUI - -struct NotificationSettingsView: View { - @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference - - @State private var presentableStatus: StatusMessage? - - private let glucoseSegments = [HKUnit.millimolesPerLiter, HKUnit.milligramsPerDeciliter] - private lazy var glucoseSegmentStrings = self.glucoseSegments.map({ $0.localizedShortUnitString }) - - private enum Key: String { - // case glucoseSchedules = "com.loopkit.libreglucoseschedules" - - case mmAlwaysDisplayGlucose = "com.loopkit.libreAlwaysDisplayGlucose" - case mmNotifyEveryXTimes = "com.loopkit.libreNotifyEveryXTimes" - case mmAlertLowBatteryWarning = "com.loopkit.libreLowBatteryWarning" - case mmAlertInvalidSensorDetected = "com.loopkit.libreInvalidSensorDetected" - // case mmAlertalarmNotifications - case mmAlertNewSensorDetected = "com.loopkit.libreNewSensorDetected" - case mmAlertNoSensorDetected = "com.loopkit.libreNoSensorDetected" - - case mmAlertSensorSoonExpire = "com.loopkit.libreAlertSensorSoonExpire" - - case mmShowPhoneBattery = "com.loopkit.libreShowPhoneBattery" - case mmShowTransmitterBattery = "com.loopkit.libreShowTransmitterBattery" - - // handle specially: - case mmGlucoseUnit = "com.loopkit.libreGlucoseUnit" - - } - - @AppStorage(Key.mmAlwaysDisplayGlucose.rawValue) var mmAlwaysDisplayGlucose: Bool = true - @AppStorage(Key.mmNotifyEveryXTimes.rawValue) var mmNotifyEveryXTimes: Int = 0 - @AppStorage(Key.mmShowPhoneBattery.rawValue) var mmShowPhoneBattery: Bool = false - @AppStorage(Key.mmShowTransmitterBattery.rawValue) var mmShowTransmitterBattery: Bool = true - - @AppStorage(Key.mmAlertLowBatteryWarning.rawValue) var mmAlertLowBatteryWarning: Bool = true - @AppStorage(Key.mmAlertInvalidSensorDetected.rawValue) var mmAlertInvalidSensorDetected: Bool = true - @AppStorage(Key.mmAlertNewSensorDetected.rawValue) var mmAlertNewSensorDetected: Bool = true - @AppStorage(Key.mmAlertNoSensorDetected.rawValue) var mmAlertNoSensorDetected: Bool = true - @AppStorage(Key.mmAlertSensorSoonExpire.rawValue) var mmAlertSensorSoonExpire: Bool = true - - - // especially handled mostly for backward compat - @AppStorage(Key.mmGlucoseUnit.rawValue) var mmGlucoseUnit: String = "" - - @State var notifyErrorState = FormErrorState() - - @State private var favoriteGlucoseUnit = 0 - - static let formatter = NumberFormatter() - - var glucoseVisibilitySection : some View { - Section(header: Text(LocalizedString("Glucose Notification visibility", comment: "Text describing header for notification visibility in notificationsettingsview")) ) { - Toggle(LocalizedString("Always Notify Glucose", comment: "Text describing always notify glucose option in notificationsettingsview"), isOn: $mmAlwaysDisplayGlucose) - - HStack { - Text(LocalizedString("Notify per reading", comment: "Text describing option for letting user choose notifying for every reading, every second reading etc")) - TextField("", value: $mmNotifyEveryXTimes, formatter: Self.formatter) - .multilineTextAlignment(.center) - .disabled(true) - .frame(minWidth: 15, maxWidth: 60) - .textFieldStyle(RoundedBorderTextFieldStyle()) - Stepper("Value", value: $mmNotifyEveryXTimes, in: 0...9) - .labelsHidden() - - }.clipped() - - - Toggle("Adds Transmitter Battery", isOn: $mmShowTransmitterBattery) - - - } - } - - var additionalNotificationsSection : some View { - Section(header: Text(LocalizedString("Additional notification types", comment: "Text describing heading for additional notification types for third party transmitters"))) { - Toggle("Low battery", isOn: $mmAlertLowBatteryWarning) - Toggle("Invalid sensor", isOn: $mmAlertInvalidSensorDetected) - Toggle("Sensor change", isOn: $mmAlertNewSensorDetected) - Toggle("Sensor not found", isOn: $mmAlertNoSensorDetected) - Toggle("Sensor expires soon", isOn: $mmAlertSensorSoonExpire) - - } - } - - /*var miscSection : some View { - Section(header: Text("Misc")) { - HStack { - Text("Unit override") - Picker(selection: $favoriteGlucoseUnit, label: Text("Unit override")) { - Text(HKUnit.millimolesPerLiter.localizedShortUnitString).tag(0) - Text(HKUnit.milligramsPerDeciliter.localizedShortUnitString).tag(1) - } - .pickerStyle(SegmentedPickerStyle()) - .clipped() - } - } - }*/ - - var body: some View { - List { - - glucoseVisibilitySection - additionalNotificationsSection - - } - .listStyle(InsetGroupedListStyle()) - .alert(item: $presentableStatus) { status in - Alert(title: Text(status.title), message: Text(status.message), dismissButton: .default(Text("Got it!"))) - } - - .navigationBarTitle("Notification") - - } - -} - -struct NotificationSettingsView_Previews: PreviewProvider { - static var previews: some View { - NotificationSettingsView() - } -} diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index ac1b6e1..3f0083d 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -69,7 +69,6 @@ struct SettingsView: View { @ObservedObject private var notifyReconnect: GenericObservableObject @State private var presentableStatus: StatusMessage? - @ObservedObject var alarmStatus: LibreTransmitter.AlarmStatus @State private var showingDestructQuestion = false // @State private var showingExporter = false @@ -86,7 +85,6 @@ struct SettingsView: View { notifyDelete: GenericObservableObject, notifyReset: GenericObservableObject, notifyReconnect: GenericObservableObject, - alarmStatus: LibreTransmitter.AlarmStatus, pairingService: SensorPairingProtocol, bluetoothSearcher: BluetoothSearcher) { @@ -97,15 +95,10 @@ struct SettingsView: View { self.notifyDelete = notifyDelete self.notifyReset = notifyReset self.notifyReconnect = notifyReconnect - self.alarmStatus = alarmStatus self.pairingService = pairingService self.bluetoothSearcher = bluetoothSearcher } - private var glucoseUnit: HKUnit { - displayGlucosePreference.unit - } - static let formatter = NumberFormatter() // no navigationview necessary when running inside a uihostingcontroller @@ -114,7 +107,6 @@ struct SettingsView: View { var body: some View { List { headerSection - snoozeSection measurementSection if let date = glucoseMeasurement.predictionDate, let prediction = glucoseMeasurement.prediction { Section(header: Text(LocalizedString("Last Blood Sugar prediction", comment: "Text describing header for Blood Sugar prediction section"))) { @@ -144,19 +136,6 @@ struct SettingsView: View { } } - var snoozeSection: some View { - Section { - NavigationLink(destination: SnoozeView(isAlarming: $alarmStatus.isAlarming, activeAlarms: $alarmStatus.glucoseScheduleAlarmResult)) { - Image(systemName: "pause.circle.fill") - .font(.system(size: 22)) - .foregroundColor(.blue) - Text(LocalizedString("Pause Glucose alarms", comment: "Text for pausing glucose alarms")).frame(alignment: .center) - .foregroundColor(.blue) - - } - } - } - var measurementSection : some View { var glucoseText: String = "" var glucoseDateText: String = "" @@ -259,26 +238,9 @@ struct SettingsView: View { var advancedSection: some View { Section(header: Text(LocalizedString("Configuration", comment: "Text describing header for advanced settings section"))) { - // these subviews don't really need to be notified once glucose unit changes - // so we just pass glucoseunit directly on init - NavigationLink(destination: AlarmSettingsView(glucoseUnit: self.glucoseUnit)) { - SettingsItem(title: "Alarms") - } - - if NotificationHelper.criticalAlarmsEnabled { - NavigationLink(destination: CriticalAlarmsVolumeView()) { - SettingsItem(title: "Critical Alarms volume") - } - } - NavigationLink(destination: GlucoseSettingsView()) { SettingsItem(title: "Glucose Settings") } - - NavigationLink(destination: NotificationSettingsView()) { - SettingsItem(title: "Notifications") - } - } } @@ -463,6 +425,6 @@ struct SettingsView: View { struct SettingsOverview_Previews: PreviewProvider { static var previews: some View { - NotificationSettingsView() + GlucoseSettingsView() } } diff --git a/LibreTransmitterUI/Views/Settings/SnoozeView.swift b/LibreTransmitterUI/Views/Settings/SnoozeView.swift deleted file mode 100644 index a637b60..0000000 --- a/LibreTransmitterUI/Views/Settings/SnoozeView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// TestView.swift -// MiaomiaoClientUI -// -// Created by LoopKit Authors on 15/10/2020. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -import LibreTransmitter -import SwiftUI - -struct SnoozeView: View { - - var pickerTimes: [TimeInterval] = ({ - pickerTimesArray() - - })() - - var formatter: DateComponentsFormatter = ({ - var f = DateComponentsFormatter() - f.allowsFractionalUnits = false - f.unitsStyle = .full - return f - - })() - - func formatInterval(_ interval: TimeInterval) -> String { - formatter.string(from: interval)! - } - - @Binding var isAlarming: Bool - @Binding var activeAlarms: LibreTransmitter.GlucoseScheduleAlarmResult - - static func pickerTimesArray() -> [TimeInterval] { - var arr = [TimeInterval]() - - let mins10 = 0.166_67 - let mins20 = mins10 * 2 - let mins30 = mins10 * 3 - // let mins40 = mins10 * 4 - - for hr in 0..<2 { - for min in [0.0, mins20, mins20 * 2] { - arr.append(TimeInterval(hours: Double(hr) + min)) - } - } - for hr in 2..<4 { - for min in [0.0, mins30] { - arr.append(TimeInterval(hours: Double(hr) + min)) - } - } - - for hr in 4...8 { - arr.append(TimeInterval(hours: Double(hr))) - } - - return arr - } - - func getSnoozeDescription() -> String { - var snoozeDescription = "" - var celltext = "" - - switch activeAlarms { - case .high: - celltext = "High Glucose Alarm active" - case .low: - celltext = "Low Glucose Alarm active" - case .none: - celltext = "No Glucose Alarm active" - } - - if let until = GlucoseScheduleList.snoozedUntil { - snoozeDescription = "snoozing until \(until.description(with: .current))" - } else { - snoozeDescription = "not snoozing" - } - - return [celltext, snoozeDescription].joined(separator: ", ") - } - - @State private var selectedInterval = 0 - @State private var snoozeDescription = "nothing to see here" - - var snoozeButton: some View { - VStack(alignment: .leading) { - Button(action: { - print("snooze from testview clicked") - let interval = pickerTimes[selectedInterval] - let snoozeFor = formatter.string(from: interval)! - let untilDate = Date() + interval - UserDefaults.standard.snoozedUntil = untilDate < Date() ? nil : untilDate - print("will snooze for \(snoozeFor) until \(untilDate.description(with: .current))") - snoozeDescription = getSnoozeDescription() - }, label: { - Text(LocalizedString("Click to Snooze Alerts", comment: "Text describing click to snooze label in snoozeview")) - .padding() - }) - } - - } - - var snoozePicker: some View { - VStack { - Picker(selection: $selectedInterval, label: Text("Strength")) { - ForEach(0 ..< pickerTimes.count, id: \.self) { - Text(formatInterval(self.pickerTimes[$0])) - } - } - - .scaledToFill() - .pickerStyle(.wheel) - } - - } - - var snoozeDesc : some View { - VStack(alignment: .leading) { - Text(snoozeDescription) - } - } - - var body: some View { - Form { - Section { - Text(snoozeDescription).lineLimit(nil) - snoozePicker - snoozeButton - } - } - .onAppear { - snoozeDescription = getSnoozeDescription() - } - } -} - -struct TestView_Previews: PreviewProvider { - static var previews: some View { - SnoozeView(isAlarming: .constant(true), activeAlarms: .constant(.none)) - } -} diff --git a/LibreTransmitterUI/Views/Setup/BluetoothSelection.swift b/LibreTransmitterUI/Views/Setup/BluetoothSelection.swift index 057f3c7..7cea70b 100644 --- a/LibreTransmitterUI/Views/Setup/BluetoothSelection.swift +++ b/LibreTransmitterUI/Views/Setup/BluetoothSelection.swift @@ -220,7 +220,6 @@ struct BluetoothSelection: View { init(cancelNotifier: GenericObservableObject, saveNotifier: GenericObservableObject, searcher: BluetoothSearcher) { self.cancelNotifier = cancelNotifier self.saveNotifier = saveNotifier - LibreTransmitter.NotificationHelper.requestNotificationPermissionsIfNeeded() self.searcher = searcher } diff --git a/LibreTransmitterUI/Views/Setup/Libre2DirectSetup.swift b/LibreTransmitterUI/Views/Setup/Libre2DirectSetup.swift index cdf4303..00cdaa9 100644 --- a/LibreTransmitterUI/Views/Setup/Libre2DirectSetup.swift +++ b/LibreTransmitterUI/Views/Setup/Libre2DirectSetup.swift @@ -59,14 +59,11 @@ struct Libre2DirectSetup: View { do { try KeychainManager.standard.setLibreNativeCalibrationData(calibrationData) } catch { - NotificationHelper.sendCalibrationNotification(.invalidCalibrationData) return } // here we assume success, data is not changed, // and we trust that the remote endpoint returns correct data for the sensor - NotificationHelper.sendCalibrationNotification(.success) - UserDefaults.standard.calibrationMapping = CalibrationToSensorMapping(uuid: info.uuid, reverseFooterCRC: calibrationData.isValidForFooterWithReverseCRCs) } @@ -83,8 +80,6 @@ struct Libre2DirectSetup: View { SelectionState.shared.selectedStringIdentifier = nil print("Paired and set selected UID to: \(String(describing: SelectionState.shared.selectedUID?.hex))") saveNotifier.notify() - NotificationHelper.sendLibre2DirectFinishedSetupNotifcation() - } var cancelButton: some View { diff --git a/build.md b/build.md index 2e600be..505885e 100644 --- a/build.md +++ b/build.md @@ -3,51 +3,6 @@ NB! This project requires LoopWorkspace dev. You *must* use the workspace to bui ## Start with a clean LoopWorkspace based on dev * Download a fresh copy of LoopWorkspace dev, which now includes the LibreTransmitter module by default. - -## Give Loop Extra background permissions - The LibreTransmitter plugin will run as a part of Loop. If you want LibreTransmitter to be able to give vibrations for low/high glucose, this is a necessary step -* In Xcode, Open the Loop Project (not the LibreTransmitter project) in the navigator, go to "Signing & Capabilities", then under "background modes", select "Audio, AirPlay, and Picture in picture". This will allow Libretransmitter to use vibration when the phone is locked. -* It should look like this: ![Loop_xcodeproj](https://user-images.githubusercontent.com/442324/111884302-14777a80-89c1-11eb-9171-76ffcef2f345.jpg "Audio/Vibrate capability added into Loop For libretransmitter to work in background") -* In code, search for and set glucoseAlarmsAlsoCauseVibration to true - - -## Give Libretransmitter Critical Alerts permissions -Libretransmitter will by default send alarms as "timesensitve", appearing immediately on the lock screen. -If you mute or set your phone to do not disturb, you can potentially miss out on such alarms. -To remedy this, LibreTransmitter can be configured to try to upgrade any glucose alarms to "critical". -Critical alarms will sound even if your phone is set to to mute or "do not disturb" mode. - -For this to be possible, you will have to request special permissions from Apple. -This process is documented at https://stackoverflow.com/questions/66057840/ios-how-do-you-implement-critical-alerts-for-your-app-when-you-dont-have-an-en . -The linked article describes some necessary code changes, but the code changes mentioned there should be ignored as the necessary code changes are already in place for Libretransmitter. - -For critical alerts to function, a custom provisioning profile must be selected. This is typically the provisioning profile you get after a successful application to Apple - -It's worth mentioning again that those permissions must be given to Loop itself, not to the LibreTransmitter package. The -com.apple.developer.usernotifications.critical-alerts permission must be added to Loop/Loop.entitlement file in the Loop folder (not inside LibreTransmitter - -Next, choose one of these two methods to enable this feature: - - -### Method 1 -Using this method, only the LibreTransmitter cgm alarms can become critical - -You should only change the shouldOverrideRequestCriticalPermissions toggle in the NotificationHelperOverride.swift file to true, like this: - -```swift -enum NotificationHelperOverride { - static var shouldOverrideRequestCriticalPermissions : Bool { - // if you want LibreTransmitter to try upgrading to critical notifications, change this - true - } -} - -``` -### Method 2 -Using this method, both Loop pump, cgm alarms and LibreTransmitter alarms will become critical - -Go to the Loop Project (not target)→Build settings → Swift Compiler → Custom flags → Other swift flags section and edit the different configuration flags. Add the flag “CRITICAL_ALERTS_ENABLED” (without the quotes). - -## Build the LoopWorkspace +## Build the LoopWorkspace * In xcode, build the LoopWorkspace as normal From 4d3ae2ac39bcd0ebc75f68a4a82e19905404a510 Mon Sep 17 00:00:00 2001 From: ETolboom <151847362+ETolboom@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:31:23 +0200 Subject: [PATCH 02/15] fix: ensure evaluateAlerts is called on delegate queue Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../LibreTransmitterManagerV3+Alerts.swift | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift index 099e8ce..44ad203 100644 --- a/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift +++ b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift @@ -12,27 +12,28 @@ import LoopKit extension LibreTransmitterManagerV3 { /// Diffs the currently-firing lifecycle alert conditions against the previous /// evaluation and issues/retracts LoopKit Alerts for the delta. - func evaluateAlerts() { - let shouldFire = LibreAlertCondition.currentlyFiring(for: sensorLifecycle) +func evaluateAlerts() { + // Serialize evaluation + state mutation to avoid races between delegate callbacks and polling. + delegateQueue.async { [weak self] in + guard let self else { return } - guard shouldFire != firingAlertConditions else { + let shouldFire = LibreAlertCondition.currentlyFiring(for: self.sensorLifecycle) + guard shouldFire != self.firingAlertConditions else { return } - let delegate = cgmManagerDelegate + let delegate = self.cgmManagerDelegate let managerIdentifier = Self.pluginIdentifier - let newlyFiring = shouldFire.subtracting(firingAlertConditions) - let noLongerFiring = firingAlertConditions.subtracting(shouldFire) + let newlyFiring = shouldFire.subtracting(self.firingAlertConditions) + let noLongerFiring = self.firingAlertConditions.subtracting(shouldFire) - delegateQueue.async { - for condition in newlyFiring { - delegate?.issueAlert(condition.alert(managerIdentifier: managerIdentifier)) - } - for condition in noLongerFiring { - delegate?.retractAlert(identifier: condition.identifier(managerIdentifier: managerIdentifier)) - } + for condition in newlyFiring { + delegate?.issueAlert(condition.alert(managerIdentifier: managerIdentifier)) + } + for condition in noLongerFiring { + delegate?.retractAlert(identifier: condition.identifier(managerIdentifier: managerIdentifier)) } - firingAlertConditions = shouldFire + self.firingAlertConditions = shouldFire } } From c8746eef7bee16ed47ed0a0d077702c250ac3de4 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 11:47:27 +0200 Subject: [PATCH 03/15] fix: push error on delegate queue, elevate expired to .critical --- LibreTransmitter/LibreTransmitterManager+Transmitters.swift | 4 +++- LibreTransmitterUI/LibreTransmitterManager+UI.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift index 3edef2a..f2f33c0 100644 --- a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift +++ b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift @@ -32,7 +32,9 @@ extension LibreTransmitterManagerV3 { } else { logger.debug("Sensor type was incorrect, and no decryption of sensor was possible") self.lastFault = .encryptedOrUnsupported - self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.encryptedSensor)) + self.delegateQueue.async { + self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.encryptedSensor)) + } return } } diff --git a/LibreTransmitterUI/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index a70618a..3d93e09 100644 --- a/LibreTransmitterUI/LibreTransmitterManager+UI.swift +++ b/LibreTransmitterUI/LibreTransmitterManager+UI.swift @@ -128,7 +128,7 @@ extension LibreTransmitterManagerV3: CGMManagerUI { let state: DeviceStatusHighlightState = remaining < TimeInterval(hours: 24) ? .warning : .normalCGM return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpiring Soon", comment: "Status highlight message for a sensor nearing expiry"), imageName: "clock", state: state) case .expired: - return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpired", comment: "Status highlight message for expired sensor"), imageName: "clock", state: .normalCGM) + return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpired", comment: "Status highlight message for expired sensor"), imageName: "clock", state: .critical) case .signalLost: return LibreStatusHighlight(localizedMessage: LocalizedString("Signal\nLoss", comment: "Status highlight message for signal loss"), imageName: "exclamationmark.circle.fill", state: .warning) case .failed: From dfecb90472c168aaf44979a3790072ec0aef746c Mon Sep 17 00:00:00 2001 From: ETolboom Date: Wed, 19 Aug 2026 10:20:47 +0200 Subject: [PATCH 04/15] fix: make sensor expiration countdown conform G7SensorKit --- LibreTransmitterUI/LibreTransmitterManager+UI.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/LibreTransmitterUI/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index 3d93e09..d8609a4 100644 --- a/LibreTransmitterUI/LibreTransmitterManager+UI.swift +++ b/LibreTransmitterUI/LibreTransmitterManager+UI.swift @@ -117,16 +117,8 @@ extension LibreTransmitterManagerV3: CGMManagerUI { switch sensorLifecycle { case .warmup: return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nWarmup", comment: "Status highlight message for sensor warmup"), imageName: "clock", state: .normalCGM) - case let .active(remaining, _): - // Matches cgmLifecycleProgress's own visibility/severity thresholds - // (see there for why 48h/24h): give the user a heads-up banner well - // before the progress bar turns warning-colored, so there's time to - // order a replacement before it becomes urgent. - guard remaining < TimeInterval(hours: 48) else { - return nil - } - let state: DeviceStatusHighlightState = remaining < TimeInterval(hours: 24) ? .warning : .normalCGM - return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpiring Soon", comment: "Status highlight message for a sensor nearing expiry"), imageName: "clock", state: state) + case .active: + return nil case .expired: return LibreStatusHighlight(localizedMessage: LocalizedString("Sensor\nExpired", comment: "Status highlight message for expired sensor"), imageName: "clock", state: .critical) case .signalLost: From 7fb1f842bf4032e16d82536e0e3760515c8a05b9 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 00:52:43 +0200 Subject: [PATCH 05/15] fix: disable redundant glucose smoothing Trio already applies its own smoothing to incoming glucose values, so the 5-point boxcar moving-average filter (CalculateSmothedData5Points) previously run on every trend reading here only added extra lag on top of Trio's own smoothing, with no benefit. Remove the filter and the now-dead GlucoseSmoothing.swift entirely. --- .../GlucoseAlgorithm/GlucoseSmoothing.swift | 47 ------------------- LibreTransmitter.xcodeproj/project.pbxproj | 4 -- LibreTransmitter/LibreGlucose.swift | 21 ++------- readme.md | 1 - 4 files changed, 3 insertions(+), 70 deletions(-) delete mode 100644 LibreSensor/GlucoseAlgorithm/GlucoseSmoothing.swift diff --git a/LibreSensor/GlucoseAlgorithm/GlucoseSmoothing.swift b/LibreSensor/GlucoseAlgorithm/GlucoseSmoothing.swift deleted file mode 100644 index 0dbc9d3..0000000 --- a/LibreSensor/GlucoseAlgorithm/GlucoseSmoothing.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// GlucoseSmoothing.swift -// MiaomiaoClientUI -// -// Created by LoopKit Authors on 25/03/2019. -// Copyright © 2019 LoopKit Authors. All rights reserved. -// - -import Foundation - -// https://github.com/NightscoutFoundation/xDrip/pull/828/files - -// private func trendToLibreGlucose(_ measurements: [Measurement]) -> [LibreGlucose]?{ - -func CalculateSmothedData5Points(origtrends: [LibreGlucose]) -> [LibreGlucose] { - // In all places in the code, there should be exactly 16 points. - // Since that might change, and I'm doing an average of 5, then in the case of less then 5 points, - // I'll only copy the data as is (to make sure there are reasonable values when the function returns). - - var trends = origtrends - // this is an adoptation, doesn't follow the original directly - if trends.count < 5 { - for i in 0 ..< trends.count { - trends[i].glucoseDouble = trends[i].unsmoothedGlucose - } - - return trends - } - for i in 0 ..< trends.count - 4 { - trends[i].glucoseDouble = (trends[i].unsmoothedGlucose + trends[i + 1].unsmoothedGlucose + - trends[i + 2].unsmoothedGlucose + - trends[i + 3].unsmoothedGlucose + - trends[i + 4].unsmoothedGlucose) / 5 - } - trends[trends.count - 4].glucoseDouble = (trends[trends.count - 4].unsmoothedGlucose + - trends[trends.count - 3].unsmoothedGlucose + - trends[trends.count - 2].unsmoothedGlucose + - trends[trends.count - 1].unsmoothedGlucose) / 4 - - trends[trends.count - 3].glucoseDouble = (trends[trends.count - 3].unsmoothedGlucose + trends[trends.count - 2].unsmoothedGlucose + trends[trends.count - 1].unsmoothedGlucose ) / 3 - - trends[trends.count - 2].glucoseDouble = (trends[trends.count - 2].unsmoothedGlucose + trends[trends.count - 1].unsmoothedGlucose ) / 2 - - trends[trends.count - 1].glucoseDouble = trends[trends.count - 2].glucoseDouble - - return trends -} diff --git a/LibreTransmitter.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index f2fc525..1a36533 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -67,7 +67,6 @@ 27850DA625672F1E0020D109 /* MiaomiaoTransmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BCA256725E70020D109 /* MiaomiaoTransmitter.swift */; }; 27850DB825672F4B0020D109 /* Calibration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BA1256724EF0020D109 /* Calibration.swift */; }; 27850DBE25672F530020D109 /* LibreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BA0256724EF0020D109 /* LibreError.swift */; }; - 27850DC425672F640020D109 /* GlucoseSmoothing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BA5256724F00020D109 /* GlucoseSmoothing.swift */; }; 27850DC525672F640020D109 /* GlucoseFromRaw.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BA4256724EF0020D109 /* GlucoseFromRaw.swift */; }; 27850DCB25672F770020D109 /* SensorSerialNumber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BAC256724F10020D109 /* SensorSerialNumber.swift */; }; 27850DCC25672F770020D109 /* Measurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27850BA8256724F00020D109 /* Measurement.swift */; }; @@ -216,7 +215,6 @@ 27850BA1256724EF0020D109 /* Calibration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calibration.swift; sourceTree = ""; }; 27850BA2256724EF0020D109 /* LimitedQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitedQueue.swift; sourceTree = ""; }; 27850BA4256724EF0020D109 /* GlucoseFromRaw.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseFromRaw.swift; sourceTree = ""; }; - 27850BA5256724F00020D109 /* GlucoseSmoothing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothing.swift; sourceTree = ""; }; 27850BA6256724F00020D109 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; 27850BA8256724F00020D109 /* Measurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Measurement.swift; sourceTree = ""; }; 27850BA9256724F00020D109 /* PreLibre2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreLibre2.swift; sourceTree = ""; }; @@ -397,7 +395,6 @@ 27850BA3256724EF0020D109 /* GlucoseAlgorithm */ = { isa = PBXGroup; children = ( - 27850BA5256724F00020D109 /* GlucoseSmoothing.swift */, 27850BA4256724EF0020D109 /* GlucoseFromRaw.swift */, 27850BA6256724F00020D109 /* .gitignore */, ); @@ -966,7 +963,6 @@ 27DD8F23268FA0F500649010 /* TransmitterInfo.swift in Sources */, 27850D9225672F020020D109 /* LibreTransmitterMetadata.swift in Sources */, 27DD8F26268FA75A00649010 /* SensorInfo.swift in Sources */, - 27850DC425672F640020D109 /* GlucoseSmoothing.swift in Sources */, 27F93B2A2816B7FF00EE39A7 /* LibreTransmitterManager+Libre2EU.swift in Sources */, 27850DCF25672F780020D109 /* CRC.swift in Sources */, 2764E86126FC6DC10016A585 /* Features.swift in Sources */, diff --git a/LibreTransmitter/LibreGlucose.swift b/LibreTransmitter/LibreGlucose.swift index 43ec73c..65c0279 100644 --- a/LibreTransmitter/LibreGlucose.swift +++ b/LibreTransmitter/LibreGlucose.swift @@ -128,15 +128,14 @@ extension LibreGlucose { static func fromTrendMeasurements(_ measurements: [Measurement], nativeCalibrationData: SensorData.CalibrationInfo) -> [LibreGlucose] { var arr = [LibreGlucose]() - var shouldSmoothGlucose = true for trend in measurements { // trend arrows on each libreglucose value is not needed // instead we calculate it once when latestbackfill is set, which in turn sets // the sensordisplayable property + let calibrated = trend.calibratedGlucose(calibrationInfo: nativeCalibrationData) let glucose = LibreGlucose( - // unsmoothedGlucose: trend.temperatureAlgorithmGlucose, - unsmoothedGlucose: trend.calibratedGlucose(calibrationInfo: nativeCalibrationData), - glucoseDouble: 0.0, + unsmoothedGlucose: calibrated, + glucoseDouble: calibrated, error: trend.error, timestamp: trend.date) // if sensor is ripped off body while transmitter is attached, values below 1 might be created @@ -144,20 +143,6 @@ extension LibreGlucose { if glucose.unsmoothedGlucose > 0 && glucose.unsmoothedGlucose <= 500 { arr.append(glucose) } - - // Just for expliciticity, if one of the values are 0, - // then the rest of the values should not be smoothed - if glucose.unsmoothedGlucose <= 0 { - shouldSmoothGlucose = false - } - } - - if shouldSmoothGlucose { - arr = CalculateSmothedData5Points(origtrends: arr) - } else { - for i in 0 ..< arr.count { - arr[i].glucoseDouble = arr[i].unsmoothedGlucose - } } return arr diff --git a/readme.md b/readme.md index 8285498..fd02ae3 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,6 @@ This is a https://github.com/loopkit/loop plugin for connecting to libresensors * Glucose values can be uploaded to nightscout automatically by Loop * Optional: Backfill the last 16 minutes of data (recommend to turn off) * Optional: Backfill the last 8 hours of data (15 minute cadence) -* Glucose data is smoothed to avoid noise, using a 5 point moving average filter * Official algorithm implements glucose prediction, to align cgm values with blood values; this feature is deliberately removed from this implementation * Glucose readout interval: 5 minutes * Glucose alarms From b6cd2f957802c980b513c9c12c3c7be9bdc005e4 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 00:41:39 +0200 Subject: [PATCH 06/15] refactor: rework CGM settings screen and persist sensor state across launches - Reorganize the settings screen: add a sensor status row (icon + text) driven by the sensor lifecycle, separate sensor identity fields from device/transmitter fields into their own section, group maintenance actions under a "Manage" section, give the pushed "Device details" screen a title, and remove the now-redundant "Glucose Settings" screen. - Fix the sensor status resolving to "No Sensor" instead of "Connecting" on a fresh launch: gate the status display on persisted pairing state first (independent of the lifecycle enum, which only resolves after a live read), and correct an ordering bug where the lifecycle was evaluated before setObservables() had updated the underlying sensor info it depends on. - Implement CGMManager.rawState/init(rawState:) persistence (previously a no-op stub), matching what other LoopKit CGM drivers already do, so sensor identity/timing/status can be restored immediately at launch instead of waiting for the first live BLE read of each session. --- Features.swift | 5 +- LibreTransmitter.xcodeproj/project.pbxproj | 12 +- .../LibreTransmitterManagerV3+Alerts.swift | 57 ++++--- LibreTransmitter/LibreCGMManagerState.swift | 75 +++++++++ .../LibreTransmitterManager+Libre2EU.swift | 6 + ...LibreTransmitterManager+Transmitters.swift | 6 + .../LibreTransmitterManagerV3.swift | 55 ++++++- LibreTransmitter/Observables/SensorInfo.swift | 11 +- .../Views/Settings/GlucoseSettingsView.swift | 71 --------- .../LibreSensorLifecycle+Display.swift | 141 +++++++++++++++++ .../Views/Settings/SettingsView.swift | 144 +++++------------- 11 files changed, 376 insertions(+), 207 deletions(-) create mode 100644 LibreTransmitter/LibreCGMManagerState.swift delete mode 100644 LibreTransmitterUI/Views/Settings/GlucoseSettingsView.swift create mode 100644 LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift diff --git a/Features.swift b/Features.swift index ec5dde2..397248c 100644 --- a/Features.swift +++ b/Features.swift @@ -13,10 +13,7 @@ import CoreNFC public final class Features { static public var logSubsystem = "com.loopkit.libre" - - static public var glucoseSettingsRequireAuthentication = false - static public var alarmSettingsViewRequiresAuthentication = false - + static public var allowsEditingFactoryCalibrationData = false static public var allowOneMinuteReadings = false diff --git a/LibreTransmitter.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index 1a36533..07f8fb5 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + D6E68AB10AF747BB8A1941F3 /* LibreCGMManagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C2C10BD0EA4E83BE81F580 /* LibreCGMManagerState.swift */; }; + 3E9EBC7F76A84FFDA055A60D /* LibreSensorLifecycle+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */; }; D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */; }; 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */; }; DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */; }; @@ -25,7 +27,6 @@ 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 */; }; - 275EC9AB265EDC280043210E /* GlucoseSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */; }; 2764E86126FC6DC10016A585 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2746C73D26DCF83400E31BD9 /* Features.swift */; }; 276EF5F22652F84F00571021 /* HashableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276EF5F12652F84F00571021 /* HashableClass.swift */; }; 277773A72639EF2B00431547 /* BlueButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277773A62639EF2B00431547 /* BlueButtonStyle.swift */; }; @@ -192,6 +193,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + D1C2C10BD0EA4E83BE81F580 /* LibreCGMManagerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreCGMManagerState.swift; sourceTree = ""; }; + B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreSensorLifecycle+Display.swift"; sourceTree = ""; }; 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreSensorLifecycle.swift; sourceTree = ""; }; CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreAlertCondition.swift; sourceTree = ""; }; 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreTransmitterManagerV3+Alerts.swift"; sourceTree = ""; }; @@ -207,7 +210,6 @@ 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 = ""; }; - 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSettingsView.swift; sourceTree = ""; }; 276EF5F12652F84F00571021 /* HashableClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashableClass.swift; sourceTree = ""; }; 277773A62639EF2B00431547 /* BlueButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueButtonStyle.swift; sourceTree = ""; }; 277773AB2639EFB800431547 /* ErrorTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTextFieldStyle.swift; sourceTree = ""; }; @@ -509,8 +511,8 @@ isa = PBXGroup; children = ( 275786AA26753CC400845D0E /* SettingsView.swift */, + B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */, 2793EBE5260BDE43001A35A3 /* CalibrationEditView.swift */, - 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */, ); path = Settings; sourceTree = ""; @@ -571,6 +573,7 @@ C1C318D42A47637100C6F29F /* Mocks */, 27ED67B62698EEEF003E5DAB /* Observables */, A8D5D1B79EF34BA3AEC8C642 /* Alerting */, + D1C2C10BD0EA4E83BE81F580 /* LibreCGMManagerState.swift */, 27850D0B25672CA60020D109 /* ConcreteGlucoseDisplayable.swift */, 27850D0C25672CA60020D109 /* LibreGlucose.swift */, B66D1F722E6A813000471149 /* Localizable.xcstrings */, @@ -918,6 +921,7 @@ D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */, 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */, DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */, + D6E68AB10AF747BB8A1941F3 /* LibreCGMManagerState.swift in Sources */, 27850CF125672C0C0020D109 /* LocalizedString.swift in Sources */, 27850D1025672CA60020D109 /* LibreGlucose.swift in Sources */, 27850D9E25672F170020D109 /* UUIDContainer.swift in Sources */, @@ -975,6 +979,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3E9EBC7F76A84FFDA055A60D /* LibreSensorLifecycle+Display.swift in Sources */, 2746C74226DD0F8800E31BD9 /* Libre2DirectSetup.swift in Sources */, 27ED67BA26990D6B003E5DAB /* GenericObservableObject.swift in Sources */, 27850CFE25672C0C0020D109 /* HKUnit.swift in Sources */, @@ -982,7 +987,6 @@ 276EF5F22652F84F00571021 /* HashableClass.swift in Sources */, 27850CF825672C0C0020D109 /* UserDefaults+Bluetooth.swift in Sources */, 275786AB26753CC400845D0E /* SettingsView.swift in Sources */, - 275EC9AB265EDC280043210E /* GlucoseSettingsView.swift in Sources */, 274E71D3297ED77300FCFECD /* AuthView.swift in Sources */, 27850CEA25672C0C0020D109 /* TimeIntervalExtensions.swift in Sources */, 275EC998265AF64E0043210E /* StatusMessage.swift in Sources */, diff --git a/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift index 44ad203..ff68a80 100644 --- a/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift +++ b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift @@ -12,28 +12,45 @@ import LoopKit extension LibreTransmitterManagerV3 { /// Diffs the currently-firing lifecycle alert conditions against the previous /// evaluation and issues/retracts LoopKit Alerts for the delta. -func evaluateAlerts() { - // Serialize evaluation + state mutation to avoid races between delegate callbacks and polling. - delegateQueue.async { [weak self] in - guard let self else { return } - - let shouldFire = LibreAlertCondition.currentlyFiring(for: self.sensorLifecycle) - guard shouldFire != self.firingAlertConditions else { - return - } + func evaluateAlerts() { + // Serialize evaluation + state mutation to avoid races between delegate callbacks and polling. + delegateQueue.async { [weak self] in + guard let self else { return } - let delegate = self.cgmManagerDelegate - let managerIdentifier = Self.pluginIdentifier - let newlyFiring = shouldFire.subtracting(self.firingAlertConditions) - let noLongerFiring = self.firingAlertConditions.subtracting(shouldFire) + let currentLifecycle = self.sensorLifecycle + let currentIsPaired = self.isDeviceSelected + DispatchQueue.main.async { + self.sensorInfoObservable.sensorLifecycle = currentLifecycle + self.sensorInfoObservable.isPaired = currentIsPaired + } - for condition in newlyFiring { - delegate?.issueAlert(condition.alert(managerIdentifier: managerIdentifier)) - } - for condition in noLongerFiring { - delegate?.retractAlert(identifier: condition.identifier(managerIdentifier: managerIdentifier)) - } + let newPersistableState = self.currentPersistableState + if newPersistableState != self.lastPersistedState { + self.lastPersistedState = newPersistableState + let delegate = self.cgmManagerDelegate + self.delegateQueue.async { + delegate?.cgmManagerDidUpdateState(self) + } + } + + let shouldFire = LibreAlertCondition.currentlyFiring(for: currentLifecycle) + guard shouldFire != self.firingAlertConditions else { + return + } - self.firingAlertConditions = shouldFire + let delegate = self.cgmManagerDelegate + let managerIdentifier = Self.pluginIdentifier + let newlyFiring = shouldFire.subtracting(self.firingAlertConditions) + let noLongerFiring = self.firingAlertConditions.subtracting(shouldFire) + + for condition in newlyFiring { + delegate?.issueAlert(condition.alert(managerIdentifier: managerIdentifier)) + } + for condition in noLongerFiring { + delegate?.retractAlert(identifier: condition.identifier(managerIdentifier: managerIdentifier)) + } + + self.firingAlertConditions = shouldFire + } } } diff --git a/LibreTransmitter/LibreCGMManagerState.swift b/LibreTransmitter/LibreCGMManagerState.swift new file mode 100644 index 0000000..785627e --- /dev/null +++ b/LibreTransmitter/LibreCGMManagerState.swift @@ -0,0 +1,75 @@ +// +// LibreCGMManagerState.swift +// LibreTransmitter +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +/// Persisted across app launches via `CGMManager.rawState`/`init(rawState:)`, +/// same as almost every other LoopKit CGM driver (e.g. `G7CGMManagerState`). +/// Without this, the plugin previously had nothing to show until the first +/// live BLE read of a session landed - every launch started from a blank +/// slate, unable to even say "we're paired to sensor X, expiring at Y" while +/// reconnecting. +public struct LibreCGMManagerState: RawRepresentable, Equatable { + public typealias RawValue = CGMManager.RawStateValue + + public var activatedAt: Date? + public var sensorMaxMinutesWearTime: Int + public var sensorSerial: String? + public var sensorType: String? + public var latestReadingTimestamp: Date? + public var lastConnected: Date? + + public init( + activatedAt: Date? = nil, + sensorMaxMinutesWearTime: Int = 0, + sensorSerial: String? = nil, + sensorType: String? = nil, + latestReadingTimestamp: Date? = nil, + lastConnected: Date? = nil + ) { + self.activatedAt = activatedAt + self.sensorMaxMinutesWearTime = sensorMaxMinutesWearTime + self.sensorSerial = sensorSerial + self.sensorType = sensorType + self.latestReadingTimestamp = latestReadingTimestamp + self.lastConnected = lastConnected + } + + public init(rawValue: RawValue) { + self.activatedAt = rawValue["activatedAt"] as? Date + self.sensorMaxMinutesWearTime = rawValue["sensorMaxMinutesWearTime"] as? Int ?? 0 + self.sensorSerial = rawValue["sensorSerial"] as? String + self.sensorType = rawValue["sensorType"] as? String + self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date + self.lastConnected = rawValue["lastConnected"] as? Date + } + + public var rawValue: RawValue { + var rawValue: RawValue = [:] + rawValue["activatedAt"] = activatedAt + rawValue["sensorMaxMinutesWearTime"] = sensorMaxMinutesWearTime + rawValue["sensorSerial"] = sensorSerial + rawValue["sensorType"] = sensorType + rawValue["latestReadingTimestamp"] = latestReadingTimestamp + rawValue["lastConnected"] = lastConnected + return rawValue + } + + /// `activatedAt + wear time`, matching how `SensorInfo.expiresAt` is + /// computed live in `setObservables` (`now + minutesLeft`, which is + /// algebraically the same thing once you substitute `minutesLeft = + /// maxMinutesWearTime - minutesSinceStart` and `activatedAt = now - + /// minutesSinceStart`). + public var expiresAt: Date? { + guard let activatedAt, sensorMaxMinutesWearTime > 0 else { + return nil + } + return activatedAt.addingTimeInterval(TimeInterval(minutes: Double(sensorMaxMinutesWearTime))) + } +} diff --git a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift index e56c7e6..b678a24 100644 --- a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift +++ b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift @@ -90,6 +90,12 @@ extension LibreTransmitterManagerV3 { } self.setObservables(sensorData: nil, bleData: bleData, metaData: Device) + // setObservables() updates sensorInfoObservable asynchronously on the main + // queue; enqueue evaluateAlerts() the same way so it's guaranteed to run + // after those updates land (GCD preserves submission order on a serial queue). + DispatchQueue.main.async { + self.evaluateAlerts() + } self.logger.debug("handleGoodReading returned with \(newGlucose.count) entries") self.delegateQueue.async { diff --git a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift index f2f33c0..6818bcd 100644 --- a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift +++ b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift @@ -135,6 +135,12 @@ extension LibreTransmitterManagerV3 { // must be inside this handler as setobservables "depend" on latestbackfill self.setObservables(sensorData: sensorData, bleData: nil, metaData: nil) + // setObservables() updates sensorInfoObservable asynchronously on the main + // queue; enqueue evaluateAlerts() the same way so it's guaranteed to run + // after those updates land (GCD preserves submission order on a serial queue). + DispatchQueue.main.async { + self.evaluateAlerts() + } self.logger.debug("handleGoodReading returned with \(newGlucoses.count) entries") self.delegateQueue.async { diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index 3974424..f3bda49 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -56,7 +56,7 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { public var sensorLifecycle: LibreSensorLifecycle { LibreSensorLifecycle.compute( - sensorPaired: hasValidSensorSession, + sensorPaired: isDeviceSelected, activatedAt: sensorInfoObservable.activatedAt, expiresAt: sensorInfoObservable.expiresAt, latestReadingAt: latestReadingTimestamp, @@ -218,8 +218,12 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { let oldValue = latestBackfill defer { + // evaluateAlerts() is deliberately not called here: it depends on + // sensorInfoObservable's activatedAt/expiresAt, which are only + // guaranteed fresh once setObservables() has run for this same + // update - callers trigger evaluateAlerts() themselves right after + // that call, using data from this same read. latestReadingTimestamp = newValue.startDate - evaluateAlerts() } logger.debug("latestBackfill set, newvalue is \(newValue.glucose)") @@ -248,11 +252,37 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { self.init() logger.debug("LibreTransmitterManager has run init from rawstate") - + + let persisted = LibreCGMManagerState(rawValue: rawState) + sensorInfoObservable.activatedAt = persisted.activatedAt + sensorInfoObservable.expiresAt = persisted.expiresAt + sensorInfoObservable.sensorMaxMinutesWearTime = persisted.sensorMaxMinutesWearTime + sensorInfoObservable.sensorSerial = persisted.sensorSerial ?? "" + transmitterInfoObservable.sensorType = persisted.sensorType ?? "" + latestReadingTimestamp = persisted.latestReadingTimestamp + lastConnected = persisted.lastConnected + lastPersistedState = persisted + } + + /// A stored snapshot of the last state actually handed to + /// `cgmManagerDelegate?.cgmManagerDidUpdateState(self)`, so `evaluateAlerts()` + /// only notifies the delegate (triggering an app-level persistence save) + /// when something persistence-relevant actually changed. + var lastPersistedState: LibreCGMManagerState? + + var currentPersistableState: LibreCGMManagerState { + LibreCGMManagerState( + activatedAt: sensorInfoObservable.activatedAt, + sensorMaxMinutesWearTime: sensorInfoObservable.sensorMaxMinutesWearTime, + sensorSerial: sensorInfoObservable.sensorSerial.isEmpty ? nil : sensorInfoObservable.sensorSerial, + sensorType: transmitterInfoObservable.sensorType.isEmpty ? nil : transmitterInfoObservable.sensorType, + latestReadingTimestamp: latestReadingTimestamp, + lastConnected: lastConnected + ) } public var rawState: CGMManager.RawStateValue { - [:] + currentPersistableState.rawValue } open var localizedTitle: String { "FreeStyle Libre" } @@ -269,6 +299,8 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { logger.debug("LibreTransmitterManager will be created now") + sensorInfoObservable.isPaired = isDeviceSelected + if isDeviceSelected { establishProxy() } @@ -283,8 +315,21 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { disconnect() transmitterInfoObservable = TransmitterInfo() sensorInfoObservable = SensorInfo() + sensorInfoObservable.isPaired = isDeviceSelected glucoseInfoObservable = GlucoseInfo() - + latestReadingTimestamp = nil + lastFault = nil + lastKnownSensorState = nil + firingAlertConditions = [] + + // Make sure the now-cleared state actually gets persisted, so a stale + // activatedAt/expiresAt from the previous sensor doesn't get restored + // on the next app launch. + lastPersistedState = nil + let delegate = cgmManagerDelegate + delegateQueue.async { + delegate?.cgmManagerDidUpdateState(self) + } } public func disconnect() { diff --git a/LibreTransmitter/Observables/SensorInfo.swift b/LibreTransmitter/Observables/SensorInfo.swift index 97bbb47..be78d91 100644 --- a/LibreTransmitter/Observables/SensorInfo.swift +++ b/LibreTransmitter/Observables/SensorInfo.swift @@ -20,7 +20,16 @@ public class SensorInfo: ObservableObject, Equatable, Hashable { @Published public var activatedAt : Date? @Published public var expiresAt : Date? - + + @Published public var sensorLifecycle: LibreSensorLifecycle = .noSensor + + /// Whether a sensor/device is paired, from persisted UserDefaults state. + /// Unlike `sensorLifecycle` (which needs at least one live data exchange + /// to resolve past its default), this is known synchronously at launch - + /// it's what lets the UI show "Connecting" instead of "No Sensor" before + /// any BLE data has arrived for this session. + @Published public var isPaired: Bool = false + public func calculateProgress() -> Double { let minutesLeft = Double(self.sensorMinutesLeft) let maxWearTime = Double(self.sensorMaxMinutesWearTime) diff --git a/LibreTransmitterUI/Views/Settings/GlucoseSettingsView.swift b/LibreTransmitterUI/Views/Settings/GlucoseSettingsView.swift deleted file mode 100644 index 72d6bd9..0000000 --- a/LibreTransmitterUI/Views/Settings/GlucoseSettingsView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// GlucoseSettingsView.swift -// LibreTransmitterUI -// -// Created by LoopKit Authors on 26/05/2021. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import SwiftUI -import Combine -import LibreTransmitter -import HealthKit - -struct GlucoseSettingsView: View { - - @State private var presentableStatus: StatusMessage? - - @AppStorage("com.loopkit.libreSyncToNs") var mmSyncToNS: Bool = true - @AppStorage("com.loopkit.libreBackfillFromHistory") var mmBackfillFromHistory: Bool = true - @AppStorage("com.loopkit.libreshouldPersistSensorData") var shouldPersistSensorData: Bool = false - - @State private var authSuccess = false - - // Set this to true to require system authentication - // for accessing the glucose section - @State private var requiresAuthentication = Features.glucoseSettingsRequireAuthentication - - var body: some View { - List { - - Section(header: Text(LocalizedString("Backfill options", comment: "Text describing header for backfill options in glucosesettingsview"))) { - Toggle("Backfill from history", isOn: $mmBackfillFromHistory) - } - Section(header: Text(LocalizedString("Remote data storage", comment: "Text describing header for remote data storage"))) { - Toggle("Upload to remote data service", isOn: $mmSyncToNS) - - } - Section(header: Text(LocalizedString("Debug options", comment: "Text describing header for debug options in glucosesettingsview")), footer: Text(LocalizedString("Adds a lot of data to the Issue Report ", comment: "Text informing user of potentially large reports"))) { - Toggle("Persist sensordata", isOn: $shouldPersistSensorData) - .onChange(of: shouldPersistSensorData) {newValue in - if !newValue { - UserDefaults.standard.queuedSensorData = nil - } - } - } - - } - .onAppear { - if requiresAuthentication && !authSuccess { - self.authenticate { success in - print("got authentication response: \(success)") - authSuccess = success - } - } - } - .disabled(requiresAuthentication ? !authSuccess : false) - .listStyle(InsetGroupedListStyle()) - .alert(item: $presentableStatus) { status in - Alert(title: Text(status.title), message: Text(status.message), dismissButton: .default(Text("Got it!"))) - } - .navigationBarTitle("Glucose Settings") - - } - -} - -struct GlucoseSettingsView_Previews: PreviewProvider { - static var previews: some View { - GlucoseSettingsView() - } -} diff --git a/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift new file mode 100644 index 0000000..32fc33c --- /dev/null +++ b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift @@ -0,0 +1,141 @@ +// +// LibreSensorLifecycle+Display.swift +// LibreTransmitterUI +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LibreTransmitter +import LoopKitUI + +/// A settings-row-friendly status derived from `LibreSensorLifecycle` plus the +/// live BLE connection state. `LibreSensorLifecycle` alone can't distinguish +/// "paired but not yet reconnected since app launch" from "truly no sensor" - +/// that distinction only exists once you also know whether there's a live +/// link right now, which is why this lives one layer above the pure lifecycle +/// enum. Mirrors SyaiKit's `SyaiSensorStatusDisplay`. +enum LibreSensorStatusDisplay: Equatable, CaseIterable { + case noSensor + case connecting + case ok + case warmingUp + case expired + case malfunction + case notActivated + case signalLost + + enum Severity { case neutral, good, warning, critical } + + var severity: Severity { + switch self { + case .connecting, .noSensor, .warmingUp: return .neutral + case .ok: return .good + case .signalLost: return .warning + case .expired, .malfunction, .notActivated: return .critical + } + } + + var iconName: String { + switch self { + case .noSensor: return "plus.circle" + case .connecting: return "arrow.triangle.2.circlepath" + case .ok: return "checkmark.circle.fill" + case .warmingUp: return "hourglass" + case .signalLost: return "antenna.radiowaves.left.and.right.slash" + case .expired, .malfunction, .notActivated: return "exclamationmark.triangle.fill" + } + } + + func iconColor(_ guidanceColors: GuidanceColors) -> Color { + switch severity { + case .neutral: return .secondary + case .good: return .green + case .warning: return guidanceColors.warning + case .critical: return guidanceColors.critical + } + } + + var title: Text { + switch self { + case .noSensor: return Text("No Sensor", comment: "status no sensor") + case .connecting: return Text("Connecting", comment: "status connecting") + case .ok: return Text("Sensor OK", comment: "status ok") + case .warmingUp: return Text("Warming Up", comment: "status warming up") + case .expired: return Text("Sensor Expired", comment: "status expired") + case .malfunction: return Text("Sensor Malfunction", comment: "status malfunction") + case .notActivated: return Text("Sensor Not Detected", comment: "status sensor not activated") + case .signalLost: return Text("Signal Loss", comment: "status signal lost") + } + } + + var message: Text { + switch self { + case .noSensor: + return Text("Pair a sensor to start receiving readings.", comment: "msg no sensor") + case .connecting: + return Text("Establishing a connection to your sensor.", comment: "msg connecting") + case .ok: + return Text("Your sensor is functioning normally.", comment: "msg ok") + case .warmingUp: + return Text("Your sensor is warming up.", comment: "msg warming up") + case .expired: + return Text("Your sensor has expired. Replace it as soon as possible.", comment: "msg expired") + case .malfunction: + return Text("Your sensor is malfunctioning. Replace it as soon as possible.", comment: "msg malfunction") + case .notActivated: + return Text( + "Your sensor is not detected or reports as invalid. If this continues, remove and re-pair it.", + comment: "msg sensor not activated" + ) + case .signalLost: + return Text( + "Signal lost. Check that your sensor is nearby and Bluetooth is on.", + comment: "msg signal lost" + ) + } + } + + /// Combines the pairing/data-driven `LibreSensorLifecycle` with a live BLE + /// connection flag - and, critically, with `isDeviceSelected` checked + /// *first* and independently of the lifecycle. `sensorLifecycle` only + /// resolves past its `.noSensor` default once at least one full data + /// exchange has happened this session (nothing is restored from persisted + /// state at launch), so gating on `lifecycle != .noSensor` alone would + /// show "No Sensor" for an already-paired sensor for the entire window + /// from app launch until the first successful read - collapsing straight + /// from "No Sensor" to whatever the resolved state turns out to be, with + /// no "Connecting" in between. Checking `isDeviceSelected` first (backed + /// by persisted UserDefaults pairing state, available synchronously, + /// independent of any BLE data) is what actually makes "Connecting" + /// reachable. Not-currently-connected wins over a stale lifecycle read + /// (e.g. `.expired`), matching SyaiKit's ordering: without a live link + /// there's no way to be sure the last-known state still holds, and it + /// resolves itself the moment the link comes back. + static func compute(lifecycle: LibreSensorLifecycle, isDeviceSelected: Bool, isConnected: Bool) -> LibreSensorStatusDisplay { + guard isDeviceSelected else { + return .noSensor + } + guard isConnected else { + return .connecting + } + switch lifecycle { + case .noSensor: + // Connected, but no data has been parsed into a resolved lifecycle yet. + return .connecting + case .warmup: + return .warmingUp + case .active: + return .ok + case .expired: + return .expired + case .signalLost: + return .signalLost + case .failed: + return .malfunction + case .unactivated: + return .notActivated + } + } +} diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index 3f0083d..a734f99 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -49,6 +49,7 @@ public struct SettingsItem: View { struct SettingsView: View { @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.guidanceColors) private var guidanceColors var longDateFormatter: DateFormatter = ({ let df = DateFormatter() @@ -114,20 +115,16 @@ struct SettingsView: View { SettingsItem(title: "Date", detail: longDateFormatter.string(from: date) ) } } - + + sensorInfoSection + NavigationLink(destination: deviceInfoSection) { SettingsItem(title: "Device details") } - - NavigationLink(destination: CalibrationEditView()) { - Button(Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { - print("edit calibration clicked") - } - } - advancedSection - sensorChangeSection + + manageSection destructSection - + }.listStyle(InsetGroupedListStyle()) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -181,14 +178,19 @@ struct SettingsView: View { if !transmitterInfo.transmitterMacAddress.isEmpty { SettingsItem(title: "Mac", detail: $transmitterInfo.transmitterMacAddress ) } - - SettingsItem(title: "Sensor Type", detail: $transmitterInfo.sensorType ) - - SettingsItem(title: "Sensor Start", detail: sensorInfo.activatedAtString ) - SettingsItem(title: "Sensor End", detail: sensorInfo.expiresAtString ) } } .textSelection(.enabled) + .navigationTitle(LocalizedString("Device Info", comment: "Text describing header for device info section")) + } + + var sensorInfoSection: some View { + Section(header: Text(LocalizedString("Sensor Information", comment: "Text describing header for sensor information section"))) { + SettingsItem(title: "Sensor Type", detail: $transmitterInfo.sensorType ) + SettingsItem(title: "Sensor Serial", detail: $sensorInfo.sensorSerial ) + SettingsItem(title: "Sensor Start", detail: sensorInfo.activatedAtString ) + SettingsItem(title: "Sensor End", detail: sensorInfo.expiresAtString ) + } } private var doneButton: some View { @@ -197,12 +199,15 @@ struct SettingsView: View { }) } - var sensorChangeSection: some View { - - Section { + var manageSection: some View { + Section(header: Text(LocalizedString("Manage", comment: "Text describing header for manage section"))) { + NavigationLink(destination: CalibrationEditView()) { + Button(Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { + print("edit calibration clicked") + } + } + NavigationLink(destination: AuthView(completeNotifier: notifyComplete, notifyReset: notifyReset, notifyReconnect: notifyReconnect, pairingService: pairingService, bluetoothSearcher: bluetoothSearcher)) { - /*Button("Change Sensor") { - }.foregroundColor(.blue)*/ SettingsItem(title: "Change Sensor").foregroundColor(.blue) } } @@ -236,14 +241,6 @@ struct SettingsView: View { } } - var advancedSection: some View { - Section(header: Text(LocalizedString("Configuration", comment: "Text describing header for advanced settings section"))) { - NavigationLink(destination: GlucoseSettingsView()) { - SettingsItem(title: "Glucose Settings") - } - } - } - private var daysRemaining: Int? { if let remaining = sensorInfo.expiresAt?.timeIntervalSinceNow, remaining > .days(1) { return Int(remaining.days) @@ -276,14 +273,6 @@ struct SettingsView: View { } } - var sensorIsExpired : Bool { - if let expiresAt = sensorInfo.expiresAt { - return expiresAt.timeIntervalSinceNow < 0 - } - - return false - } - var showProgress : Bool { if let expiresAt = sensorInfo.expiresAt { @@ -347,84 +336,35 @@ struct SettingsView: View { }.frame(maxWidth: .infinity) } - var sensorStatusText : String { - let ret = sensorInfo.sensorState - return ret.isEmpty ? " - " : ret - } - var sensorStatus: some View { - VStack(alignment: .leading, spacing: 0) { - Text(LocalizedString("Sensor State", comment: "Text describing Sensor state label in settingsview")) - .fontWeight(.heavy) - .fixedSize() - Text("\(sensorStatusText)") - .foregroundColor(.secondary) - .textSelection(.enabled) - } - } - - var sensorSerialText : String { - let ret = sensorInfo.sensorSerial - print("got serial: \(ret)") - return ret.isEmpty ? " - " : ret + private var isConnected: Bool { + ["Connected", "Notifying"].contains(transmitterInfo.connectionState) } - - var sensorSerial : some View { - VStack(alignment: .leading, spacing: 0) { - Text(LocalizedString("Sensor Serial", comment: "Text describing Sensor serial label in settingsview")) - // .font(.system(size: 1)) - .fontWeight(.heavy) - .fixedSize() - Text("\(sensorSerialText)") - .foregroundColor(.secondary) - .textSelection(.enabled) + + var sensorStatusRow: some View { + let status = LibreSensorStatusDisplay.compute(lifecycle: sensorInfo.sensorLifecycle, isDeviceSelected: sensorInfo.isPaired, isConnected: isConnected) + return HStack(alignment: .top, spacing: 10) { + Image(systemName: status.iconName) + .foregroundStyle(status.iconColor(guidanceColors)) + VStack(alignment: .leading, spacing: 2) { + status.title.fontWeight(.heavy).foregroundStyle(.primary) + status.message.foregroundStyle(.secondary) + } } } - + var headerSection: some View { Section { VStack(alignment: .trailing) { - + Spacer() headerImage - + lifecycleProgress Spacer() - HStack(alignment: .top) { - sensorStatus - Spacer() - sensorSerial - } - - /*Divider() - Text("some faultAction") - .font(Font.footnote.weight(.semibold)) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - */ - - } - if sensorIsExpired { - VStack(alignment: .leading, spacing: 4) { - Text("Sensor is expired") - .font(Font.subheadline.weight(.bold)) - Text("Replace sensor immediately to continue receving glucose values") - .font(Font.footnote.weight(.semibold)) - }.padding(.vertical, 8) - } else if !["Notifying", "Connected"].contains(transmitterInfo.connectionState) || !showProgress { - VStack(alignment: .leading, spacing: 4) { - Text(LocalizedString("No Connection: ", comment: "Text describing no connection label in settingsview")) - .font(Font.subheadline.weight(.bold)) - Text("\(transmitterInfo.connectionState)") - .font(Font.footnote.weight(.semibold)) - }.padding(.vertical, 8) + } + sensorStatusRow } } } - -struct SettingsOverview_Previews: PreviewProvider { - static var previews: some View { - GlucoseSettingsView() - } -} From 8b7b2bb3079e460c7fedb0978e6f065a2d48b578 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 10:55:49 +0200 Subject: [PATCH 07/15] feat: add battery status row for bridge --- .../LibreTransmitterManagerV3.swift | 1 + .../Observables/TransmitterInfo.swift | 3 ++- .../Views/Settings/SettingsView.swift | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index f3bda49..5f79d85 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -420,6 +420,7 @@ extension LibreTransmitterManagerV3 { if let metaData=metaData { self.logger.debug("will set transmitterInfoObservable") self.transmitterInfoObservable.battery = metaData.batteryString + self.transmitterInfoObservable.batteryPercent = metaData.battery self.transmitterInfoObservable.hardware = metaData.hardware ?? "" self.transmitterInfoObservable.firmware = metaData.firmware ?? "" self.transmitterInfoObservable.sensorType = metaData.sensorType()?.description ?? "Unknown" diff --git a/LibreTransmitter/Observables/TransmitterInfo.swift b/LibreTransmitter/Observables/TransmitterInfo.swift index 127f505..aa43634 100644 --- a/LibreTransmitter/Observables/TransmitterInfo.swift +++ b/LibreTransmitter/Observables/TransmitterInfo.swift @@ -8,6 +8,7 @@ public class TransmitterInfo: ObservableObject, Equatable, Hashable { @Published public var battery = "" + @Published public var batteryPercent: Int? @Published public var hardware = "" @Published public var firmware = "" @Published public var connectionState = "" @@ -16,7 +17,7 @@ public class TransmitterInfo: ObservableObject, Equatable, Hashable { @Published public var sensorType = "" public static func == (lhs: TransmitterInfo, rhs: TransmitterInfo) -> Bool { - lhs.battery == rhs.battery && lhs.hardware == rhs.hardware && + lhs.battery == rhs.battery && lhs.batteryPercent == rhs.batteryPercent && lhs.hardware == rhs.hardware && lhs.firmware == rhs.firmware && lhs.connectionState == rhs.connectionState && lhs.transmitterType == rhs.transmitterType && lhs.transmitterMacAddress == rhs.transmitterMacAddress && lhs.sensorType == rhs.sensorType diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index a734f99..b8dd172 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -352,6 +352,29 @@ struct SettingsView: View { } } + // Bridge transmitter (MiaoMiao/Bubble/Blucon, etc) battery is hardware state, + // independent of the sensor's own lifecycle. As such its kept as its own row rather than + // folded into `LibreSensorStatusDisplay`'s severity, so it can surface + // regardless of what the sensor status above is currently showing. + private var isBridgeBatteryLow: Bool { + guard let percent = transmitterInfo.batteryPercent else { return false } + return percent <= 20 + } + + var bridgeBatteryRow: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "battery.25") + .foregroundStyle(guidanceColors.warning) + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedString("Bridge battery low", comment: "Title for a warning that the bridge transmitter's battery is low")) + .fontWeight(.heavy) + .foregroundStyle(.primary) + Text(LocalizedString("Consider charging your transmitter soon.", comment: "Message for a warning that the bridge transmitter's battery is low")) + .foregroundStyle(.secondary) + } + } + } + var headerSection: some View { Section { VStack(alignment: .trailing) { @@ -364,6 +387,9 @@ struct SettingsView: View { } sensorStatusRow + if isBridgeBatteryLow { + bridgeBatteryRow + } } } From 8518d555540c7977c68ae3c8d52ef9f43a6ee470 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 12:53:21 +0200 Subject: [PATCH 08/15] fix: prefer enum for connection state strings --- LibreTransmitterUI/Views/Settings/SettingsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index b8dd172..5234a25 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -337,7 +337,7 @@ struct SettingsView: View { } private var isConnected: Bool { - ["Connected", "Notifying"].contains(transmitterInfo.connectionState) + [BluetoothmanagerState.Connected, .Notifying].map(\.rawValue).contains(transmitterInfo.connectionState) } var sensorStatusRow: some View { From dbfefc25fe6d45b28b882ca03145726dfd267111 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 12:58:28 +0200 Subject: [PATCH 09/15] fix: update compute docstring --- .../LibreSensorLifecycle+Display.swift | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift index 32fc33c..7b19cae 100644 --- a/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift +++ b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift @@ -99,20 +99,22 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { /// Combines the pairing/data-driven `LibreSensorLifecycle` with a live BLE /// connection flag - and, critically, with `isDeviceSelected` checked - /// *first* and independently of the lifecycle. `sensorLifecycle` only - /// resolves past its `.noSensor` default once at least one full data - /// exchange has happened this session (nothing is restored from persisted - /// state at launch), so gating on `lifecycle != .noSensor` alone would - /// show "No Sensor" for an already-paired sensor for the entire window - /// from app launch until the first successful read - collapsing straight - /// from "No Sensor" to whatever the resolved state turns out to be, with - /// no "Connecting" in between. Checking `isDeviceSelected` first (backed - /// by persisted UserDefaults pairing state, available synchronously, - /// independent of any BLE data) is what actually makes "Connecting" + /// *first* and independently of the lifecycle. `sensorLifecycle` can + /// resolve past its `.noSensor` default from persisted state restored at + /// launch (`LibreCGMManagerState` via `init(rawState:)`) just as well as + /// from a live data exchange this session - but a persisted resolution + /// can be stale until a live connection is reestablished, so gating on + /// `lifecycle != .noSensor` alone would show whatever the last-known + /// state was as if it were current for the entire window from app launch + /// until the first successful read - collapsing straight from a + /// possibly-stale resolved state to whatever the freshly resolved state + /// turns out to be, with no "Connecting" in between. Checking + /// `isDeviceSelected` first (backed by persisted UserDefaults pairing + /// state, available synchronously, independent of any BLE data) is what + /// actually makes "Connecting" /// reachable. Not-currently-connected wins over a stale lifecycle read - /// (e.g. `.expired`), matching SyaiKit's ordering: without a live link - /// there's no way to be sure the last-known state still holds, and it - /// resolves itself the moment the link comes back. + /// (e.g. `.expired`); without a live link there's no way to be sure the last-known state still holds, + /// and it resolves itself the moment the link comes back. static func compute(lifecycle: LibreSensorLifecycle, isDeviceSelected: Bool, isConnected: Bool) -> LibreSensorStatusDisplay { guard isDeviceSelected else { return .noSensor From e9eea5e0bcb6d6443a9ea03a6c8434c4a33f61e7 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 23:58:32 +0200 Subject: [PATCH 10/15] chore: sync Localizable.xcstrings with existing sensor alert/status strings Backfills catalog entries for strings introduced in earlier commits (LibreAlertCondition, the bridge battery row, sensor lifecycle status rows) that were never synced into the String Catalogs. --- LibreTransmitter/Localizable.xcstrings | 24 ++++++++ LibreTransmitterUI/Localizable.xcstrings | 78 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/LibreTransmitter/Localizable.xcstrings b/LibreTransmitter/Localizable.xcstrings index 4d46680..35f01c3 100644 --- a/LibreTransmitter/Localizable.xcstrings +++ b/LibreTransmitter/Localizable.xcstrings @@ -1761,6 +1761,30 @@ } } }, + "Sensor Expired" : { + "comment" : "Title for sensor expired alert" + }, + "Sensor expired. Replace your sensor now." : { + "comment" : "Body for sensor expired alert" + }, + "Sensor Malfunction" : { + "comment" : "Title for sensor malfunction alert" + }, + "Sensor malfunction. Replace your sensor now." : { + "comment" : "Body for sensor malfunction alert" + }, + "Sensor Not Detected" : { + "comment" : "Title for sensor not detected alert" + }, + "Sensor not detected or reporting as invalid. If this continues, remove and re-pair it." : { + "comment" : "Body for sensor not detected alert" + }, + "Signal Loss" : { + "comment" : "Title for signal loss sensor alert" + }, + "Signal lost. Check that your sensor is nearby and Bluetooth is on." : { + "comment" : "Body for signal loss sensor alert" + }, "U" : { "comment" : "The short unit display string for international units of insulin", "localizations" : { diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 840774a..12a8de1 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -2563,6 +2563,9 @@ } } }, + "Bridge battery low" : { + "comment" : "Title for a warning that the bridge transmitter's battery is low" + }, "Calibration Details" : { "localizations" : { "ar" : { @@ -3288,6 +3291,9 @@ } } }, + "Connecting" : { + "comment" : "status connecting" + }, "Connection options" : { "comment" : "Text describing options for connecting to sensor or transmitter", "localizations" : { @@ -3431,6 +3437,9 @@ } } }, + "Consider charging your transmitter soon." : { + "comment" : "Message for a warning that the bridge transmitter's battery is low" + }, "Credentials" : { "comment" : "Title of cell to set credentials", "extractionState" : "manual", @@ -5455,6 +5464,9 @@ } } }, + "Establishing a connection to your sensor." : { + "comment" : "msg connecting" + }, "Fair warning: The sensor will be not be using the manufacturer's algorithm, and some safety mitigations present in the manufacturers algorithm might be missing when you use this." : { "comment" : "Label text for step 4 of connection setup", "localizations" : { @@ -8348,6 +8360,9 @@ } } }, + "Manage" : { + "comment" : "Text describing header for manage section" + }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", "localizations" : { @@ -9348,6 +9363,9 @@ } } }, + "No Sensor" : { + "comment" : "status no sensor" + }, "Notification" : { "localizations" : { "ar" : { @@ -9789,6 +9807,9 @@ } } }, + "Pair a sensor to start receiving readings." : { + "comment" : "msg no sensor" + }, "Pair Sensor" : { "comment" : "Button title for pairing sensor", "localizations" : { @@ -10945,6 +10966,9 @@ } } }, + "Replace\nSensor" : { + "comment" : "Status highlight message for a failed sensor" + }, "Replace sensor immediately to continue receving glucose values" : { "localizations" : { "ar" : { @@ -11653,6 +11677,18 @@ } } }, + "Sensor\nExpired" : { + "comment" : "Status highlight message for expired sensor" + }, + "Sensor\nExpiring Soon" : { + "comment" : "Status highlight message for a sensor nearing expiry" + }, + "Sensor\nNot Detected" : { + "comment" : "Status highlight message for a sensor that is not detected" + }, + "Sensor\nWarmup" : { + "comment" : "Status highlight message for sensor warmup" + }, "Sensor change" : { "localizations" : { "ar" : { @@ -11795,6 +11831,9 @@ } } }, + "Sensor Expired" : { + "comment" : "status expired" + }, "Sensor expires in " : { "comment" : "Text describing sensor expires in label in settingsview", "localizations" : { @@ -12080,6 +12119,9 @@ } } }, + "Sensor Information" : { + "comment" : "Text describing header for sensor information section" + }, "Sensor is expired" : { "localizations" : { "ar" : { @@ -12222,6 +12264,12 @@ } } }, + "Sensor Malfunction" : { + "comment" : "status malfunction" + }, + "Sensor Not Detected" : { + "comment" : "status sensor not activated" + }, "Sensor not found" : { "localizations" : { "ar" : { @@ -12364,6 +12412,9 @@ } } }, + "Sensor OK" : { + "comment" : "status ok" + }, "Sensor Serial" : { "comment" : "Text describing Sensor serial label in settingsview", "localizations" : { @@ -12949,6 +13000,15 @@ } } }, + "Signal\nLoss" : { + "comment" : "Status highlight message for signal loss" + }, + "Signal Loss" : { + "comment" : "status signal lost" + }, + "Signal lost. Check that your sensor is nearby and Bluetooth is on." : { + "comment" : "msg signal lost" + }, "Strength" : { "localizations" : { "ar" : { @@ -14700,6 +14760,24 @@ } } }, + "Warming Up" : { + "comment" : "status warming up" + }, + "Your sensor has expired. Replace it as soon as possible." : { + "comment" : "msg expired" + }, + "Your sensor is functioning normally." : { + "comment" : "msg ok" + }, + "Your sensor is malfunctioning. Replace it as soon as possible." : { + "comment" : "msg malfunction" + }, + "Your sensor is not detected or reports as invalid. If this continues, remove and re-pair it." : { + "comment" : "msg sensor not activated" + }, + "Your sensor is warming up." : { + "comment" : "msg warming up" + }, "Your sensor must be activated and fully warmed up." : { "comment" : "Label text for step 1 of libre2 setup", "localizations" : { From a09f33267e33c81af0e0ef9bdb1ece7a42969f5d Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 23:58:45 +0200 Subject: [PATCH 11/15] fix: propagate measurement error flags and show sensor-specific CGM title LibreGlucose's initializer accepted a MeasurementError array but never assigned it to self.error, so every reading silently reported .OK regardless of what the sensor's raw error bitfield actually said. Also show the live sensor type (e.g. "Libre 2") as the settings screen title instead of the generic "FreeStyle Libre". --- LibreTransmitter/LibreGlucose.swift | 1 + LibreTransmitter/LibreTransmitterManagerV3.swift | 4 +++- LibreTransmitter/Localizable.xcstrings | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/LibreTransmitter/LibreGlucose.swift b/LibreTransmitter/LibreGlucose.swift index 65c0279..1212ea3 100644 --- a/LibreTransmitter/LibreGlucose.swift +++ b/LibreTransmitter/LibreGlucose.swift @@ -23,6 +23,7 @@ public struct LibreGlucose: Codable, Hashable { public init(unsmoothedGlucose: Double, glucoseDouble: Double, error: [MeasurementError] = [MeasurementError.OK], timestamp: Date) { self.unsmoothedGlucose = unsmoothedGlucose self.glucoseDouble = glucoseDouble + self.error = error self.timestamp = timestamp } diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index 5f79d85..a692163 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -285,7 +285,9 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { currentPersistableState.rawValue } - open var localizedTitle: String { "FreeStyle Libre" } + open var localizedTitle: String { + transmitterInfoObservable.sensorType.isEmpty ? LocalizedString("FreeStyle Libre", comment: "Generic fallback title for the CGM settings screen before a sensor type is known") : transmitterInfoObservable.sensorType + } public let appURL: URL? = nil // URL(string: "spikeapp://") diff --git a/LibreTransmitter/Localizable.xcstrings b/LibreTransmitter/Localizable.xcstrings index 35f01c3..0cdeaac 100644 --- a/LibreTransmitter/Localizable.xcstrings +++ b/LibreTransmitter/Localizable.xcstrings @@ -729,6 +729,9 @@ } } }, + "FreeStyle Libre" : { + "comment" : "Generic fallback title for the CGM settings screen before a sensor type is known" + }, "g" : { "comment" : "The short unit display string for grams", "localizations" : { From 11d28e6d4f2542c1f16af5dc19161cb364f5800a Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 23:59:01 +0200 Subject: [PATCH 12/15] feat: surface sensor measurement errors as "Sensor Issues" in settings Decode the raw per-reading firmware error/quality bits (temperature out of range, rate-of-change too fast, signal saturated, etc.) that were already being parsed but silently dropped, and surface them in the sensor status row instead of showing "Sensor OK" when the sensor is actively reporting a problem. Wording for TEMP_HIGH, TEMP_LOW, THERMISTOR_OUT_OF_RANGE, PEAK_DELTA_EXCEEDED, AVG_DELTA_EXCEEDED, SIGNAL_SATURATED, SENSOR_SIGNAL_LOW, and INVALID_DATA is grounded in Abbott's own Android app data-processing semantics; the remaining cases are best-effort placeholders pending official terminology. --- LibreSensor/SensorContents/Measurement.swift | 2 +- LibreTransmitter.xcodeproj/project.pbxproj | 4 ++ .../Alerting/MeasurementError+Display.swift | 44 +++++++++++++++++++ .../LibreTransmitterManagerV3.swift | 1 + LibreTransmitter/Localizable.xcstrings | 39 ++++++++++++++++ LibreTransmitter/Observables/SensorInfo.swift | 5 +++ LibreTransmitterUI/Localizable.xcstrings | 3 ++ .../LibreSensorLifecycle+Display.swift | 23 ++++++++-- .../Views/Settings/SettingsView.swift | 7 ++- 9 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 LibreTransmitter/Alerting/MeasurementError+Display.swift diff --git a/LibreSensor/SensorContents/Measurement.swift b/LibreSensor/SensorContents/Measurement.swift index c9a4d81..f77b4e6 100644 --- a/LibreSensor/SensorContents/Measurement.swift +++ b/LibreSensor/SensorContents/Measurement.swift @@ -25,7 +25,7 @@ extension MeasurementProtocol { } } -public enum MeasurementError: Int, CaseIterable, Codable { +public enum MeasurementError: Int, CaseIterable, Codable, Equatable { case OK = 0 case SD14_FIFO_OVERFLOW = 1 case FILTER_DELTA = 0x02 diff --git a/LibreTransmitter.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index 07f8fb5..d9b90ce 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3E9EBC7F76A84FFDA055A60D /* LibreSensorLifecycle+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */; }; D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */; }; 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */; }; + 5DD090AA90A3461FFC2CE17C /* MeasurementError+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B6C1EBA2082D9D5837DDDC /* MeasurementError+Display.swift */; }; DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */; }; 2735A6E029637DEA00D4E868 /* LibreTransmitter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 432B0E881CDFC3C50045347B /* LibreTransmitter.framework */; }; 2735A6E129637DEA00D4E868 /* LibreTransmitter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 432B0E881CDFC3C50045347B /* LibreTransmitter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -197,6 +198,7 @@ B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreSensorLifecycle+Display.swift"; sourceTree = ""; }; 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreSensorLifecycle.swift; sourceTree = ""; }; CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreAlertCondition.swift; sourceTree = ""; }; + E4B6C1EBA2082D9D5837DDDC /* MeasurementError+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MeasurementError+Display.swift"; sourceTree = ""; }; 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibreTransmitterManagerV3+Alerts.swift"; sourceTree = ""; }; 2709A6F229E5E0D3004355A3 /* FakeSensorPairingData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeSensorPairingData.swift; sourceTree = ""; }; 27394068296C2A48001DD18D /* libresensor200.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = libresensor200.png; sourceTree = ""; }; @@ -534,6 +536,7 @@ 2AFC6FD1B7BC4094B6402F98 /* LibreSensorLifecycle.swift */, CB7CEA2FC56B4E108F0B9FAC /* LibreAlertCondition.swift */, 7021854176DD41F1AA5B4E1F /* LibreTransmitterManagerV3+Alerts.swift */, + E4B6C1EBA2082D9D5837DDDC /* MeasurementError+Display.swift */, ); path = Alerting; sourceTree = ""; @@ -921,6 +924,7 @@ D42EC5E068404A889272225A /* LibreSensorLifecycle.swift in Sources */, 06F862064B214505ABA15302 /* LibreAlertCondition.swift in Sources */, DC0BD5CEC12B4881B6EA4101 /* LibreTransmitterManagerV3+Alerts.swift in Sources */, + 5DD090AA90A3461FFC2CE17C /* MeasurementError+Display.swift in Sources */, D6E68AB10AF747BB8A1941F3 /* LibreCGMManagerState.swift in Sources */, 27850CF125672C0C0020D109 /* LocalizedString.swift in Sources */, 27850D1025672CA60020D109 /* LibreGlucose.swift in Sources */, diff --git a/LibreTransmitter/Alerting/MeasurementError+Display.swift b/LibreTransmitter/Alerting/MeasurementError+Display.swift new file mode 100644 index 0000000..e442fb4 --- /dev/null +++ b/LibreTransmitter/Alerting/MeasurementError+Display.swift @@ -0,0 +1,44 @@ +// +// MeasurementError+Display.swift +// LibreTransmitter +// +// Created by LoopKit Authors. +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +public extension MeasurementError { + var localizedDescription: String { + switch self { + case .OK: + return "" + case .SD14_FIFO_OVERFLOW: + return LocalizedString("Sensor data buffer overflow.", comment: "Placeholder description for SD14_FIFO_OVERFLOW sensor measurement error") + case .FILTER_DELTA: + return LocalizedString("Reading changed too abruptly to trust.", comment: "Placeholder description for FILTER_DELTA sensor measurement error") + case .WORK_VOLTAGE: + return LocalizedString("Sensor working voltage out of range.", comment: "Placeholder description for WORK_VOLTAGE sensor measurement error") + case .PEAK_DELTA_EXCEEDED: + return LocalizedString("A single reading changed too abruptly to be physiologically plausible and was discarded.", comment: "Description for PEAK_DELTA_EXCEEDED sensor measurement error: one anomalous sample-to-sample spike, rejected as an implausible single-reading jump") + case .AVG_DELTA_EXCEEDED: + return LocalizedString("Glucose is changing faster than physiologically plausible; readings are being discarded until it stabilizes.", comment: "Description for AVG_DELTA_EXCEEDED sensor measurement error: rate of change over several readings too fast to trust") + case .RF: + return LocalizedString("Radio signal interference detected.", comment: "Placeholder description for RF sensor measurement error") + case .REF_R: + return LocalizedString("Sensor reference resistance out of range.", comment: "Placeholder description for REF_R sensor measurement error") + case .SIGNAL_SATURATED: + return LocalizedString("Sensor signal is saturated and can't be converted to a glucose value.", comment: "Description for SIGNAL_SATURATED sensor measurement error: raw signal clipped at its maximum") + case .SENSOR_SIGNAL_LOW: + return LocalizedString("Sensor signal is too weak to measure reliably.", comment: "Description for SENSOR_SIGNAL_LOW sensor measurement error: weak/degraded electrochemical signal, e.g. failing sensor or poor insertion") + case .THERMISTOR_OUT_OF_RANGE: + return LocalizedString("Sensor's temperature sensor is reporting an implausible value.", comment: "Description for THERMISTOR_OUT_OF_RANGE sensor measurement error: raw thermistor reading outside its plausible electrical range - a hardware fault, not a temporary condition") + case .TEMP_HIGH: + return LocalizedString("Sensor is too hot to measure glucose. This should resolve on its own within a few minutes.", comment: "Description for TEMP_HIGH sensor measurement error, matching Abbott's own \"Sensor Too Hot\" wording") + case .TEMP_LOW: + return LocalizedString("Sensor is too cold to measure glucose. This should resolve on its own within a few minutes.", comment: "Description for TEMP_LOW sensor measurement error, matching Abbott's own \"Sensor Too Cold\" wording") + case .INVALID_DATA: + return LocalizedString("Sensor data failed validation and can't be processed.", comment: "Description for INVALID_DATA sensor measurement error: corrupt or inconsistent payload/frame") + } + } +} diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index a692163..fa1009b 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -531,6 +531,7 @@ extension LibreTransmitterManagerV3 { self.logger.debug("will set glucoseInfoObservable") self.glucoseInfoObservable.glucose = d.quantity self.glucoseInfoObservable.date = d.timestamp + self.sensorInfoObservable.activeMeasurementErrors = d.error.filter { $0 != .OK } } if let d = self.latestPrediction { diff --git a/LibreTransmitter/Localizable.xcstrings b/LibreTransmitter/Localizable.xcstrings index 0cdeaac..a4b2307 100644 --- a/LibreTransmitter/Localizable.xcstrings +++ b/LibreTransmitter/Localizable.xcstrings @@ -1788,6 +1788,45 @@ "Signal lost. Check that your sensor is nearby and Bluetooth is on." : { "comment" : "Body for signal loss sensor alert" }, + "A single reading changed too abruptly to be physiologically plausible and was discarded." : { + "comment" : "Description for PEAK_DELTA_EXCEEDED sensor measurement error: one anomalous sample-to-sample spike, rejected as an implausible single-reading jump" + }, + "Glucose is changing faster than physiologically plausible; readings are being discarded until it stabilizes." : { + "comment" : "Description for AVG_DELTA_EXCEEDED sensor measurement error: rate of change over several readings too fast to trust" + }, + "Radio signal interference detected." : { + "comment" : "Placeholder description for RF sensor measurement error" + }, + "Reading changed too abruptly to trust." : { + "comment" : "Placeholder description for FILTER_DELTA sensor measurement error" + }, + "Sensor data buffer overflow." : { + "comment" : "Placeholder description for SD14_FIFO_OVERFLOW sensor measurement error" + }, + "Sensor data failed validation and can't be processed." : { + "comment" : "Description for INVALID_DATA sensor measurement error: corrupt or inconsistent payload/frame" + }, + "Sensor reference resistance out of range." : { + "comment" : "Placeholder description for REF_R sensor measurement error" + }, + "Sensor signal is saturated and can't be converted to a glucose value." : { + "comment" : "Description for SIGNAL_SATURATED sensor measurement error: raw signal clipped at its maximum" + }, + "Sensor signal is too weak to measure reliably." : { + "comment" : "Description for SENSOR_SIGNAL_LOW sensor measurement error: weak/degraded electrochemical signal, e.g. failing sensor or poor insertion" + }, + "Sensor is too cold to measure glucose. This should resolve on its own within a few minutes." : { + "comment" : "Description for TEMP_LOW sensor measurement error, matching Abbott's own \"Sensor Too Cold\" wording" + }, + "Sensor is too hot to measure glucose. This should resolve on its own within a few minutes." : { + "comment" : "Description for TEMP_HIGH sensor measurement error, matching Abbott's own \"Sensor Too Hot\" wording" + }, + "Sensor working voltage out of range." : { + "comment" : "Placeholder description for WORK_VOLTAGE sensor measurement error" + }, + "Sensor's temperature sensor is reporting an implausible value." : { + "comment" : "Description for THERMISTOR_OUT_OF_RANGE sensor measurement error: raw thermistor reading outside its plausible electrical range - a hardware fault, not a temporary condition" + }, "U" : { "comment" : "The short unit display string for international units of insulin", "localizations" : { diff --git a/LibreTransmitter/Observables/SensorInfo.swift b/LibreTransmitter/Observables/SensorInfo.swift index be78d91..b81357d 100644 --- a/LibreTransmitter/Observables/SensorInfo.swift +++ b/LibreTransmitter/Observables/SensorInfo.swift @@ -23,6 +23,11 @@ public class SensorInfo: ObservableObject, Equatable, Hashable { @Published public var sensorLifecycle: LibreSensorLifecycle = .noSensor + /// Non-OK error/quality bits from the most recent measurement, decoded straight + /// from the sensor's own firmware. Surfaced as "Sensor issues" in the settings UI + /// alongside (but independent of) the coarser `sensorLifecycle` state. + @Published public var activeMeasurementErrors: [MeasurementError] = [] + /// Whether a sensor/device is paired, from persisted UserDefaults state. /// Unlike `sensorLifecycle` (which needs at least one live data exchange /// to resolve past its default), this is known synchronously at launch - diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 12a8de1..20fa766 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -12264,6 +12264,9 @@ } } }, + "Sensor Issues" : { + "comment" : "status measurement issue" + }, "Sensor Malfunction" : { "comment" : "status malfunction" }, diff --git a/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift index 7b19cae..f0dc35c 100644 --- a/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift +++ b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift @@ -16,7 +16,7 @@ import LoopKitUI /// that distinction only exists once you also know whether there's a live /// link right now, which is why this lives one layer above the pure lifecycle /// enum. Mirrors SyaiKit's `SyaiSensorStatusDisplay`. -enum LibreSensorStatusDisplay: Equatable, CaseIterable { +enum LibreSensorStatusDisplay: Equatable { case noSensor case connecting case ok @@ -25,6 +25,11 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { case malfunction case notActivated case signalLost + /// Sensor is otherwise reporting normally, but its most recent measurement carried + /// one or more non-fatal firmware error/quality bits (e.g. temperature out of range, + /// rate of change too fast). Distinct from - and lower priority than - the lifecycle + /// states above, which all take precedence when active. + case measurementIssue([MeasurementError]) enum Severity { case neutral, good, warning, critical } @@ -32,7 +37,7 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { switch self { case .connecting, .noSensor, .warmingUp: return .neutral case .ok: return .good - case .signalLost: return .warning + case .signalLost, .measurementIssue: return .warning case .expired, .malfunction, .notActivated: return .critical } } @@ -45,6 +50,7 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { case .warmingUp: return "hourglass" case .signalLost: return "antenna.radiowaves.left.and.right.slash" case .expired, .malfunction, .notActivated: return "exclamationmark.triangle.fill" + case .measurementIssue: return "exclamationmark.triangle" } } @@ -67,6 +73,7 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { case .malfunction: return Text("Sensor Malfunction", comment: "status malfunction") case .notActivated: return Text("Sensor Not Detected", comment: "status sensor not activated") case .signalLost: return Text("Signal Loss", comment: "status signal lost") + case .measurementIssue: return Text("Sensor Issues", comment: "status measurement issue") } } @@ -94,6 +101,8 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { "Signal lost. Check that your sensor is nearby and Bluetooth is on.", comment: "msg signal lost" ) + case let .measurementIssue(errors): + return Text(errors.map(\.localizedDescription).joined(separator: "\n")) } } @@ -115,7 +124,12 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { /// reachable. Not-currently-connected wins over a stale lifecycle read /// (e.g. `.expired`); without a live link there's no way to be sure the last-known state still holds, /// and it resolves itself the moment the link comes back. - static func compute(lifecycle: LibreSensorLifecycle, isDeviceSelected: Bool, isConnected: Bool) -> LibreSensorStatusDisplay { + static func compute( + lifecycle: LibreSensorLifecycle, + isDeviceSelected: Bool, + isConnected: Bool, + measurementErrors: [MeasurementError] = [] + ) -> LibreSensorStatusDisplay { guard isDeviceSelected else { return .noSensor } @@ -129,7 +143,8 @@ enum LibreSensorStatusDisplay: Equatable, CaseIterable { case .warmup: return .warmingUp case .active: - return .ok + let issues = measurementErrors.filter { $0 != .OK } + return issues.isEmpty ? .ok : .measurementIssue(issues) case .expired: return .expired case .signalLost: diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index 5234a25..c538985 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -341,7 +341,12 @@ struct SettingsView: View { } var sensorStatusRow: some View { - let status = LibreSensorStatusDisplay.compute(lifecycle: sensorInfo.sensorLifecycle, isDeviceSelected: sensorInfo.isPaired, isConnected: isConnected) + let status = LibreSensorStatusDisplay.compute( + lifecycle: sensorInfo.sensorLifecycle, + isDeviceSelected: sensorInfo.isPaired, + isConnected: isConnected, + measurementErrors: sensorInfo.activeMeasurementErrors + ) return HStack(alignment: .top, spacing: 10) { Image(systemName: status.iconName) .foregroundStyle(status.iconColor(guidanceColors)) From 6137da11a1db61aa3805c66c44db140896e7a9b3 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Tue, 18 Aug 2026 23:59:15 +0200 Subject: [PATCH 13/15] fix: remove blood glucose prediction The 10-minute-ahead linear regression extrapolation shown as "Last Blood Sugar prediction" was display-only, but a speculative future value like this is actively dangerous to keep around in a codebase that feeds an automated insulin dosing system - it invites exactly the kind of misuse (treating a guess as data) an OS-AID pipeline can't afford. Removed the section and its entire pipeline: the per-sensor prediction call sites, the linear regression helper, and the now-dead GlucoseArrayWithPrediction/prediction observable fields. --- LibreSensor/SensorContents/SensorData.swift | 52 ------- .../LibreTransmitterManager+Libre2EU.swift | 1 - ...LibreTransmitterManager+Transmitters.swift | 29 ++-- .../LibreTransmitterManagerV3.swift | 37 +---- .../Observables/GlucoseInfo.swift | 5 +- LibreTransmitterUI/Localizable.xcstrings | 143 ------------------ .../Views/Settings/SettingsView.swift | 6 - 7 files changed, 12 insertions(+), 261 deletions(-) diff --git a/LibreSensor/SensorContents/SensorData.swift b/LibreSensor/SensorContents/SensorData.swift index 59c16d3..b9a07a9 100644 --- a/LibreSensor/SensorContents/SensorData.swift +++ b/LibreSensor/SensorContents/SensorData.swift @@ -439,55 +439,3 @@ extension SensorData { } } -public extension Array where Element == Measurement { - private func average(_ input: [Double]) -> Double { - return input.reduce(0, +) / Double(input.count) - } - - private func multiply(_ a: [Double], _ b: [Double]) -> [Double] { - return zip(a, b).map(*) - } - // https://github.com/raywenderlich/swift-algorithm-club/blob/master/Linear%20Regression/LinearRegression.playground/Contents.swift - private func linearRegression(_ xs: [Double], _ ys: [Double]) -> (Double) -> Double { - let sum1 = average(multiply(xs, ys)) - average(xs) * average(ys) - let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2) - let slope = sum1 / sum2 - let intercept = average(ys) - slope * average(xs) - return { x in intercept + slope * x } - } - - func predictBloodSugar(_ minutes: Double = 10) -> Measurement? { - - guard count > 15 else { - return first - } - - guard let first else { - return nil - } - let sorted = sorted { $0.date < $1.date} - - // keep the recent raw temperatures, we don't want to apply linear regression to them - let mostRecentTemperature = first.rawTemperature - let mostRecentAdjustment = first.rawTemperatureAdjustment - let mostRecentDate = first.date - let futureDate = mostRecentDate.addingTimeInterval(60*minutes) - - let glucoseAge = sorted.compactMap { measurement in - Double(measurement.date.timeIntervalSince1970) - } - - let rawGlucoseValues = sorted.compactMap { measurement in - Double(measurement.rawGlucose) - } - - let glucosePrediction = linearRegression(glucoseAge, rawGlucoseValues)(futureDate.timeIntervalSince1970) - - let predicted = Measurement(date: futureDate, - rawGlucose: Int(glucosePrediction.rounded()), - rawTemperature: mostRecentTemperature, - rawTemperatureAdjustment: mostRecentAdjustment) - return predicted - - } -} diff --git a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift index b678a24..ec2757a 100644 --- a/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift +++ b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift @@ -84,7 +84,6 @@ extension LibreTransmitterManagerV3 { self.countTimesWithoutData &+= 1 } else { self.latestBackfill = glucose.max { $0.startDate < $1.startDate } - self.latestPrediction = self.createBloodSugarPrediction(bleData.trend, calibration: calibrationData) self.logger.debug("latestbackfill set to \(self.latestBackfill.debugDescription)") self.countTimesWithoutData = 0 } diff --git a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift index 6818bcd..83ad42d 100644 --- a/LibreTransmitter/LibreTransmitterManager+Transmitters.swift +++ b/LibreTransmitter/LibreTransmitterManager+Transmitters.swift @@ -78,7 +78,7 @@ extension LibreTransmitterManagerV3 { - self.handleGoodReading(data: sensorData) { [weak self] error, glucoseArrayWithPrediction in + self.handleGoodReading(data: sensorData) { [weak self] error, glucoseReadout in guard let self else { print(" handleGoodReading could not lock on self, aborting") return @@ -91,7 +91,7 @@ extension LibreTransmitterManagerV3 { return } - guard let glucose = glucoseArrayWithPrediction?.trends else { + guard let glucose = glucoseReadout?.trends else { self.logger.debug("handleGoodReading returned with no data") self.delegateQueue.async { self.cgmManagerDelegate?.cgmManager(self, hasNew: .noData) @@ -99,27 +99,25 @@ extension LibreTransmitterManagerV3 { return } - let prediction = glucoseArrayWithPrediction?.prediction - var newGlucoses : [NewGlucoseSample] = [] - + // Since trends have a spacing of 1 minute between them, we use that to calculate trend arrows var trends = self.glucosesToSamplesFilter(glucose, startDate: self.getStartDateForFilter()) - + // But since Loop only supports 1 glucose reading // every 5 minutes, we remove all readings except the newest if let newest = trends.first { trends = [newest] } - + // Historical readings have a spacing of 15 minutes between them, // trend arrow calculation doesn't make that much sense - if let historical = glucoseArrayWithPrediction?.historical { + if let historical = glucoseReadout?.historical { let historical2 = self.glucosesToSamplesFilter(historical, startDate: self.getStartDateForFilter(), calculateTrends: false) if !historical.isEmpty { newGlucoses = historical2 } - + } newGlucoses += trends @@ -131,8 +129,6 @@ extension LibreTransmitterManagerV3 { self.countTimesWithoutData = 0 } - self.latestPrediction = prediction?.first - // must be inside this handler as setobservables "depend" on latestbackfill self.setObservables(sensorData: sensorData, bleData: nil, metaData: nil) // setObservables() updates sensorInfoObservable asynchronously on the main @@ -158,18 +154,13 @@ extension LibreTransmitterManagerV3 { } } - private func readingToGlucose(_ data: SensorData, calibration: SensorData.CalibrationInfo) -> GlucoseArrayWithPrediction { + private func readingToGlucose(_ data: SensorData, calibration: SensorData.CalibrationInfo) -> GlucoseReadout { var entries: [LibreGlucose] = [] var historical: [LibreGlucose] = [] - var prediction: [LibreGlucose] = [] let trends = data.trendMeasurements() - if let temp = createBloodSugarPrediction(trends, calibration: calibration) { - prediction.append(temp) - } - entries = LibreGlucose.fromTrendMeasurements(trends, nativeCalibrationData: calibration) if UserDefaults.standard.mmBackfillFromHistory { @@ -177,10 +168,10 @@ extension LibreTransmitterManagerV3 { historical += LibreGlucose.fromHistoryMeasurements(history, nativeCalibrationData: calibration) } - return (trends: entries, historical: historical, prediction: prediction) + return (trends: entries, historical: historical) } - public func handleGoodReading(data: SensorData?, _ callback: @escaping (LibreError?, GlucoseArrayWithPrediction?) -> Void) { + public func handleGoodReading(data: SensorData?, _ callback: @escaping (LibreError?, GlucoseReadout?) -> Void) { // only care about the once per minute readings here, historical data will not be considered guard let data else { diff --git a/LibreTransmitter/LibreTransmitterManagerV3.swift b/LibreTransmitter/LibreTransmitterManagerV3.swift index fa1009b..ad1067f 100644 --- a/LibreTransmitter/LibreTransmitterManagerV3.swift +++ b/LibreTransmitter/LibreTransmitterManagerV3.swift @@ -23,7 +23,7 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { - public typealias GlucoseArrayWithPrediction = (trends: [LibreGlucose], historical: [LibreGlucose], prediction: [LibreGlucose]) + public typealias GlucoseReadout = (trends: [LibreGlucose], historical: [LibreGlucose]) public lazy var logger = Logger(forType: Self.self) public let isOnboarded = true // No distinction between created and onboarded @@ -206,8 +206,6 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { public var lastConnected: Date? - internal var latestPrediction: LibreGlucose? - public var latestBackfill: LibreGlucose? { willSet(newValue) { guard let newValue else { @@ -391,30 +389,6 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { // MARK: - Convenience functions extension LibreTransmitterManagerV3 { - internal func createBloodSugarPrediction(_ measurements: [Measurement], calibration: SensorData.CalibrationInfo) -> LibreGlucose? { - let allGlucoses = measurements.sorted { $0.date > $1.date } - - // Increase to up to 15 to move closer to real blood sugar - // The cost is slightly more noise on consecutive readings - let glucosePredictionMinutes: Double = 10 - - guard allGlucoses.count > 15 else { - logger.info("not creating blood sugar prediction: less data elements than needed (\(allGlucoses.count))") - return nil - } - - if let predicted = allGlucoses.predictBloodSugar(glucosePredictionMinutes) { - let currentBg = predicted.calibratedGlucose(calibrationInfo: calibration) - let bgDate = predicted.date.addingTimeInterval(60 * -glucosePredictionMinutes) - logger.debug("Predicted glucose (not used) was: \(currentBg)") - return LibreGlucose(unsmoothedGlucose: currentBg, glucoseDouble: currentBg, timestamp: bgDate) - } else { - logger.debug("Tried to predict glucose value but failed!") - return nil - } - - } - public func setObservables(sensorData: SensorDataProtocol?, bleData: Libre2.LibreBLEResponse?, metaData: LibreTransmitterMetadata?) { logger.debug("setObservables called") DispatchQueue.main.async { @@ -533,15 +507,6 @@ extension LibreTransmitterManagerV3 { self.glucoseInfoObservable.date = d.timestamp self.sensorInfoObservable.activeMeasurementErrors = d.error.filter { $0 != .OK } } - - if let d = self.latestPrediction { - self.glucoseInfoObservable.prediction = d.quantity - self.glucoseInfoObservable.predictionDate = d.timestamp - - } else { - self.glucoseInfoObservable.prediction = nil - self.glucoseInfoObservable.predictionDate = nil - } } } diff --git a/LibreTransmitter/Observables/GlucoseInfo.swift b/LibreTransmitter/Observables/GlucoseInfo.swift index 2b51dd1..4daab9a 100644 --- a/LibreTransmitter/Observables/GlucoseInfo.swift +++ b/LibreTransmitter/Observables/GlucoseInfo.swift @@ -16,12 +16,9 @@ public class GlucoseInfo: ObservableObject, Equatable, Hashable { @Published public var checksum = "" // @Published var entryErrors = "" - @Published public var prediction: HKQuantity? - @Published public var predictionDate: Date? - public static func ==(lhs: GlucoseInfo, rhs: GlucoseInfo) -> Bool { lhs.glucose == rhs.glucose && lhs.date == rhs.date && - lhs.checksum == rhs.checksum && lhs.prediction == rhs.prediction && lhs.predictionDate == rhs.predictionDate + lhs.checksum == rhs.checksum } } diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 20fa766..c6a092d 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -7205,149 +7205,6 @@ } } }, - "Last Blood Sugar prediction" : { - "comment" : "Text describing header for Blood Sugar prediction section", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "آخر تنبؤ بمستوى الجلوكوز في الدم" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sidste forudsigelse af blodsukker" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Letzte Blutzuckervorhersage" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Última predicción del nivel de glucosa" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last Blood Sugar prediction" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dernière prédiction de glycémie" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last Blood Sugar prediction" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utolsó vércukor-előrejelzés" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ultima previsione glicemia" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siste blodsukkerprediksjon" - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Siste blodsukker-prediksjon" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Laatste glucosewaarde-voorspelling" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ostatnia prognoza poziomu cukru we krwi" - } - }, - "pt" : { - "stringUnit" : { - "state" : "new", - "value" : "Last Blood Sugar prediction" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Last Blood Sugar prediction" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ultima predicție privind glicemia" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Последнее предсказание глюкозы" - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Posledná predpoveď glykémie" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Senaste blodsockerprognos" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Son KŞ'i tahmini" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Останнє передбачення глюкози" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dự đoán lượng đường trong máu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "最后一次血糖预测" - } - } - } - }, "Last measurement" : { "comment" : "Text describing header for last measurement section", "localizations" : { diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index c538985..dcab3a9 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -109,12 +109,6 @@ struct SettingsView: View { List { headerSection measurementSection - if let date = glucoseMeasurement.predictionDate, let prediction = glucoseMeasurement.prediction { - Section(header: Text(LocalizedString("Last Blood Sugar prediction", comment: "Text describing header for Blood Sugar prediction section"))) { - SettingsItem(title: "CurrentBG", detail: displayGlucosePreference.format(prediction)) - SettingsItem(title: "Date", detail: longDateFormatter.string(from: date) ) - } - } sensorInfoSection From 3e3a7792d519d145474260efae3dd4045972f366 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Wed, 19 Aug 2026 00:37:28 +0200 Subject: [PATCH 14/15] fix: restore navigation titles on CGM settings screen and sub-screens SettingsView has no NavigationStack, so .navigationTitle never reached UIKit. Titles for the root screen, device details, and calibrations are now set directly on navigationItem, matching SyaiKit's pattern. Also fixes the calibration warning banner sitting outside the List and breaking title layout. --- .../LibreTransmitterManager+UI.swift | 36 +++++- LibreTransmitterUI/Localizable.xcstrings | 43 +++++++ .../Views/Settings/CalibrationEditView.swift | 13 ++- .../Views/Settings/SettingsView.swift | 109 +++++++++++------- .../Utilities/GenericObservableObject.swift | 12 ++ 5 files changed, 160 insertions(+), 53 deletions(-) diff --git a/LibreTransmitterUI/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index d8609a4..09fe00e 100644 --- a/LibreTransmitterUI/LibreTransmitterManager+UI.swift +++ b/LibreTransmitterUI/LibreTransmitterManager+UI.swift @@ -52,11 +52,14 @@ extension LibreTransmitterManagerV3: CGMManagerUI { let doneNotifier = GenericObservableObject() let wantToTerminateNotifier = GenericObservableObject() - + let wantToResetCGMManagerNotifier = GenericObservableObject() - + let wantToRestablishConnectionNotifier = GenericObservableObject() + let wantToShowDeviceDetailsNotifier = GenericObservableObject() + let wantToShowCalibrationsNotifier = GenericObservableObject() + let settingsView = SettingsView( transmitterInfo: self.transmitterInfoObservable, sensorInfo: self.sensorInfoObservable, @@ -65,20 +68,43 @@ extension LibreTransmitterManagerV3: CGMManagerUI { notifyDelete: wantToTerminateNotifier, notifyReset: wantToResetCGMManagerNotifier, notifyReconnect:wantToRestablishConnectionNotifier, + notifyShowDeviceDetails: wantToShowDeviceDetailsNotifier, + notifyShowCalibrations: wantToShowCalibrationsNotifier, pairingService: self.pairingService, bluetoothSearcher: self.bluetoothSearcher ) + // SettingsView has no NavigationStack/NavigationView, so SwiftUI's own .navigationTitle + // has nothing to propagate through - neither DismissibleHostingController nor + // CGMManagerSettingsNavigationViewController bridge that preference into UIKit's + // navigationItem.title. Titles for this screen and everything it pushes are set + // directly on navigationItem below instead (matching SyaiKit's SyaiUIController). let hostedView = DismissibleHostingController( content: settingsView - .navigationTitle(self.localizedTitle) .environmentObject(displayGlucosePreference) ) + hostedView.navigationItem.title = self.localizedTitle + hostedView.navigationItem.largeTitleDisplayMode = .always let nav = CGMManagerSettingsNavigationViewController(rootViewController: hostedView) - nav.navigationItem.largeTitleDisplayMode = .always nav.navigationBar.prefersLargeTitles = true - + + wantToShowDeviceDetailsNotifier.listen { [weak self, weak nav] in + guard let self, let nav else { return } + let detailHost = DismissibleHostingController(content: DeviceInfoView(transmitterInfo: self.transmitterInfoObservable)) + detailHost.navigationItem.title = LocalizedString("Device Info", comment: "Text describing header for device info section") + nav.pushViewController(detailHost, animated: true) + } + + wantToShowCalibrationsNotifier.listen { [weak nav] in + guard let nav else { return } + let calibrationHost = DismissibleHostingController(content: CalibrationEditView()) + calibrationHost.navigationItem.title = Features.allowsEditingFactoryCalibrationData + ? LocalizedString("Calibration Edit", comment: "Title for calibration edit screen") + : LocalizedString("Calibration Details", comment: "Title for calibration details screen") + nav.pushViewController(calibrationHost, animated: true) + } + wantToResetCGMManagerNotifier.listenOnce { [weak self] in self?.logger.debug("CGM wants to reset cgmmanager") self?.resetManager() diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index c6a092d..8dc06da 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -2,6 +2,7 @@ "sourceLanguage" : "en", "strings" : { "" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -411,6 +412,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -696,6 +698,7 @@ } }, "Active from - to " : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -839,6 +842,7 @@ }, "Additional notification types" : { "comment" : "Text describing heading for additional notification types for third party transmitters", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -982,6 +986,7 @@ }, "Adds a lot of data to the Issue Report " : { "comment" : "Text informing user of potentially large reports", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1124,6 +1129,7 @@ } }, "Adds Transmitter Battery" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1266,6 +1272,7 @@ } }, "Alarm Settings" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1409,6 +1416,7 @@ }, "Always Notify Glucose" : { "comment" : "Text describing always notify glucose option in notificationsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2136,6 +2144,7 @@ } }, "Backfill from history" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2279,6 +2288,7 @@ }, "Backfill options" : { "comment" : "Text describing header for backfill options in glucosesettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2567,6 +2577,7 @@ "comment" : "Title for a warning that the bridge transmitter's battery is low" }, "Calibration Details" : { + "comment" : "Title for calibration details screen", "localizations" : { "ar" : { "stringUnit" : { @@ -2709,6 +2720,7 @@ } }, "Calibration Edit" : { + "comment" : "Title for calibration edit screen", "localizations" : { "ar" : { "stringUnit" : { @@ -3013,6 +3025,7 @@ }, "Click to Snooze Alerts" : { "comment" : "Text describing click to snooze label in snoozeview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3156,6 +3169,7 @@ }, "Configuration" : { "comment" : "Text describing header for advanced settings section", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3597,6 +3611,7 @@ } }, "Critical alarm volume" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3739,6 +3754,7 @@ } }, "Critical alarms will always be sent with volume at minimum 60%" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -4324,6 +4340,7 @@ }, "Debug options" : { "comment" : "Text describing header for debug options in glucosesettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5323,6 +5340,7 @@ } }, "Edit calibrations" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6066,6 +6084,7 @@ }, "Glucose Notification visibility" : { "comment" : "Text describing header for notification visibility in notificationsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6208,6 +6227,7 @@ } }, "Glucose Settings" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6493,6 +6513,7 @@ }, "High" : { "comment" : "Text describing High glucose label in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6921,6 +6942,7 @@ } }, "Invalid sensor" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -7934,6 +7956,7 @@ }, "Low" : { "comment" : "Text describing Low glucose label in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -8076,6 +8099,7 @@ } }, "Low battery" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9079,6 +9103,7 @@ }, "No Connection: " : { "comment" : "Text describing no connection label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9224,6 +9249,7 @@ "comment" : "status no sensor" }, "Notification" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9367,6 +9393,7 @@ }, "Notify per reading" : { "comment" : "Text describing option for letting user choose notifying for every reading, every second reading etc", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10111,6 +10138,7 @@ }, "Pause Glucose alarms" : { "comment" : "Text for pausing glucose alarms", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10253,6 +10281,7 @@ } }, "Persist sensordata" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10682,6 +10711,7 @@ }, "Remote data storage" : { "comment" : "Text describing header for remote data storage", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10827,6 +10857,7 @@ "comment" : "Status highlight message for a failed sensor" }, "Replace sensor immediately to continue receving glucose values" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11107,6 +11138,7 @@ }, "Schedule " : { "comment" : "Text describing schedule in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11547,6 +11579,7 @@ "comment" : "Status highlight message for sensor warmup" }, "Sensor change" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11835,6 +11868,7 @@ } }, "Sensor expires soon" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11980,6 +12014,7 @@ "comment" : "Text describing header for sensor information section" }, "Sensor is expired" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12131,6 +12166,7 @@ "comment" : "status sensor not activated" }, "Sensor not found" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12277,6 +12313,7 @@ }, "Sensor Serial" : { "comment" : "Text describing Sensor serial label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12563,6 +12600,7 @@ }, "Sensor State" : { "comment" : "Text describing Sensor state label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12870,6 +12908,7 @@ "comment" : "msg signal lost" }, "Strength" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13299,6 +13338,7 @@ }, "To " : { "comment" : "Very short text describing separation between start and end datetimes", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13740,6 +13780,7 @@ } }, "Upload to remote data service" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14337,6 +14378,7 @@ } }, "Value" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14479,6 +14521,7 @@ } }, "View factory calibrations" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/LibreTransmitterUI/Views/Settings/CalibrationEditView.swift b/LibreTransmitterUI/Views/Settings/CalibrationEditView.swift index ded913b..c2ff27f 100644 --- a/LibreTransmitterUI/Views/Settings/CalibrationEditView.swift +++ b/LibreTransmitterUI/Views/Settings/CalibrationEditView.swift @@ -115,19 +115,22 @@ struct CalibrationEditView: View { var body: some View { - if !Features.allowsEditingFactoryCalibrationData { - NotificationView(text: "To modify these settings you need to modify the code to allow it") - } List { + if !Features.allowsEditingFactoryCalibrationData { + Section { + NotificationView(text: "To modify these settings you need to modify the code to allow it") + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + } + } calibrationInputsSections validForSection if Features.allowsEditingFactoryCalibrationData { saveButtonSection } - + } .listStyle(InsetGroupedListStyle()) - .navigationBarTitle(Features.allowsEditingFactoryCalibrationData ? "Calibration Edit" : "Calibration Details") } @ObservedObject private var newParams: Params diff --git a/LibreTransmitterUI/Views/Settings/SettingsView.swift b/LibreTransmitterUI/Views/Settings/SettingsView.swift index dcab3a9..bfd4203 100644 --- a/LibreTransmitterUI/Views/Settings/SettingsView.swift +++ b/LibreTransmitterUI/Views/Settings/SettingsView.swift @@ -68,6 +68,8 @@ struct SettingsView: View { @ObservedObject private var notifyDelete: GenericObservableObject @ObservedObject private var notifyReset: GenericObservableObject @ObservedObject private var notifyReconnect: GenericObservableObject + @ObservedObject private var notifyShowDeviceDetails: GenericObservableObject + @ObservedObject private var notifyShowCalibrations: GenericObservableObject @State private var presentableStatus: StatusMessage? @@ -86,6 +88,8 @@ struct SettingsView: View { notifyDelete: GenericObservableObject, notifyReset: GenericObservableObject, notifyReconnect: GenericObservableObject, + notifyShowDeviceDetails: GenericObservableObject, + notifyShowCalibrations: GenericObservableObject, pairingService: SensorPairingProtocol, bluetoothSearcher: BluetoothSearcher) { @@ -96,15 +100,14 @@ struct SettingsView: View { self.notifyDelete = notifyDelete self.notifyReset = notifyReset self.notifyReconnect = notifyReconnect + self.notifyShowDeviceDetails = notifyShowDeviceDetails + self.notifyShowCalibrations = notifyShowCalibrations self.pairingService = pairingService self.bluetoothSearcher = bluetoothSearcher } static let formatter = NumberFormatter() - // no navigationview necessary when running inside a uihostingcontroller - // uihostingcontroller seems to add a navigationview for us, causing problems if we - // also add one herer var body: some View { List { headerSection @@ -112,8 +115,8 @@ struct SettingsView: View { sensorInfoSection - NavigationLink(destination: deviceInfoSection) { - SettingsItem(title: "Device details") + disclosureButton(title: "Device details") { + notifyShowDeviceDetails.notify() } manageSection @@ -127,6 +130,22 @@ struct SettingsView: View { } } + /// A List row that looks like a NavigationLink (label + disclosure chevron) but triggers + /// a plain action instead, for rows whose destination is pushed manually in UIKit. + func disclosureButton(title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + SettingsItem(title: title) + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + var measurementSection : some View { var glucoseText: String = "" var glucoseDateText: String = "" @@ -144,40 +163,6 @@ struct SettingsView: View { } } - var deviceInfoSection: some View { - List { - Section(header: Text(LocalizedString("Device Info", comment: "Text describing header for device info section"))) { - if !transmitterInfo.battery.isEmpty { - SettingsItem(title: "Battery", detail: $transmitterInfo.battery ) - } - - // The firmware version is not always extractable for all devices - // and the libre2 direct version does not support it at all - if !transmitterInfo.hardware.isEmpty { - SettingsItem(title: "Hardware", detail: $transmitterInfo.hardware ) - } - // The firmware version is not always extractable for all devices - // and the libre2 direct version does not support it at all - if !transmitterInfo.firmware.isEmpty { - SettingsItem(title: "Firmware", detail: $transmitterInfo.firmware ) - } - - SettingsItem(title: "Connection State", detail: $transmitterInfo.connectionState ) - SettingsItem(title: "Transmitter Type", detail: $transmitterInfo.transmitterType ) - - // The mac address of a given device is normally not available on ios - // Only the bluetooth identifier, which is a normalized derivative of the mac address is available - // However, some transmitters, such as the bubble, provide their own mac address as part of its advertisement info - // which we extract and put herer - if !transmitterInfo.transmitterMacAddress.isEmpty { - SettingsItem(title: "Mac", detail: $transmitterInfo.transmitterMacAddress ) - } - } - } - .textSelection(.enabled) - .navigationTitle(LocalizedString("Device Info", comment: "Text describing header for device info section")) - } - var sensorInfoSection: some View { Section(header: Text(LocalizedString("Sensor Information", comment: "Text describing header for sensor information section"))) { SettingsItem(title: "Sensor Type", detail: $transmitterInfo.sensorType ) @@ -195,10 +180,8 @@ struct SettingsView: View { var manageSection: some View { Section(header: Text(LocalizedString("Manage", comment: "Text describing header for manage section"))) { - NavigationLink(destination: CalibrationEditView()) { - Button(Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { - print("edit calibration clicked") - } + disclosureButton(title: Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { + notifyShowCalibrations.notify() } NavigationLink(destination: AuthView(completeNotifier: notifyComplete, notifyReset: notifyReset, notifyReconnect: notifyReconnect, pairingService: pairingService, bluetoothSearcher: bluetoothSearcher)) { @@ -393,3 +376,43 @@ struct SettingsView: View { } } + +/// Pushed manually as its own hosting controller (see +/// LibreTransmitterManagerV3.settingsViewController) rather than via NavigationLink, so its +/// navigationItem.title can be set directly in UIKit - see the comment on SettingsView.body. +struct DeviceInfoView: View { + @ObservedObject var transmitterInfo: LibreTransmitter.TransmitterInfo + + var body: some View { + List { + Section(header: Text(LocalizedString("Device Info", comment: "Text describing header for device info section"))) { + if !transmitterInfo.battery.isEmpty { + SettingsItem(title: "Battery", detail: $transmitterInfo.battery ) + } + + // The firmware version is not always extractable for all devices + // and the libre2 direct version does not support it at all + if !transmitterInfo.hardware.isEmpty { + SettingsItem(title: "Hardware", detail: $transmitterInfo.hardware ) + } + // The firmware version is not always extractable for all devices + // and the libre2 direct version does not support it at all + if !transmitterInfo.firmware.isEmpty { + SettingsItem(title: "Firmware", detail: $transmitterInfo.firmware ) + } + + SettingsItem(title: "Connection State", detail: $transmitterInfo.connectionState ) + SettingsItem(title: "Transmitter Type", detail: $transmitterInfo.transmitterType ) + + // The mac address of a given device is normally not available on ios + // Only the bluetooth identifier, which is a normalized derivative of the mac address is available + // However, some transmitters, such as the bubble, provide their own mac address as part of its advertisement info + // which we extract and put herer + if !transmitterInfo.transmitterMacAddress.isEmpty { + SettingsItem(title: "Mac", detail: $transmitterInfo.transmitterMacAddress ) + } + } + } + .textSelection(.enabled) + } +} diff --git a/LibreTransmitterUI/Views/Utilities/GenericObservableObject.swift b/LibreTransmitterUI/Views/Utilities/GenericObservableObject.swift index de7cdb1..4414e7b 100644 --- a/LibreTransmitterUI/Views/Utilities/GenericObservableObject.swift +++ b/LibreTransmitterUI/Views/Utilities/GenericObservableObject.swift @@ -26,4 +26,16 @@ class GenericObservableObject: ObservableObject { .store(in: &cancellables) return self } + + /// Like `listenOnce`, but keeps listening for the lifetime of this object instead of + /// tearing itself down after the first notification. Use for repeatable actions (e.g. + /// navigation triggers that can fire more than once per screen), not one-shot ones. + @discardableResult func listen(listener: @escaping () -> Void) -> Self { + objectWillChange + .sink { _ in + listener() + } + .store(in: &cancellables) + return self + } } From 245619c3e3d0dd834f823ae0db070e7e7a045cc5 Mon Sep 17 00:00:00 2001 From: ETolboom Date: Wed, 19 Aug 2026 10:26:15 +0200 Subject: [PATCH 15/15] fix: remove stale "Sensor\nExpiring Soon" localization key Left behind by the stack rebase: the sync commit on this branch re-added the key independently of the fix (now living on feat/cgm-lifecycle+alerts) that removed the code referencing it. --- LibreTransmitterUI/Localizable.xcstrings | 3 --- 1 file changed, 3 deletions(-) diff --git a/LibreTransmitterUI/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 8dc06da..6561c10 100644 --- a/LibreTransmitterUI/Localizable.xcstrings +++ b/LibreTransmitterUI/Localizable.xcstrings @@ -11569,9 +11569,6 @@ "Sensor\nExpired" : { "comment" : "Status highlight message for expired sensor" }, - "Sensor\nExpiring Soon" : { - "comment" : "Status highlight message for a sensor nearing expiry" - }, "Sensor\nNot Detected" : { "comment" : "Status highlight message for a sensor that is not detected" },