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..397248c 100644 --- a/Features.swift +++ b/Features.swift @@ -13,21 +13,10 @@ 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 - - // 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/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/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/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.xcodeproj/project.pbxproj b/LibreTransmitter.xcodeproj/project.pbxproj index 2e9477d..d9b90ce 100644 --- a/LibreTransmitter.xcodeproj/project.pbxproj +++ b/LibreTransmitter.xcodeproj/project.pbxproj @@ -7,8 +7,12 @@ 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 */; }; + 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 */; }; + 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, ); }; }; 2735A6E429637DEA00D4E868 /* LibreTransmitterUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43A8EC82210E664300A81379 /* LibreTransmitterUI.framework */; }; @@ -21,17 +25,13 @@ 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 +45,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 */; }; @@ -75,7 +69,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 */; }; @@ -94,7 +87,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 +194,13 @@ /* 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 = ""; }; + 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 = ""; }; - 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,21 +209,16 @@ 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 = ""; }; 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 = ""; }; @@ -251,21 +242,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 +261,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 +383,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 = ( @@ -423,7 +399,6 @@ 27850BA3256724EF0020D109 /* GlucoseAlgorithm */ = { isa = PBXGroup; children = ( - 27850BA5256724F00020D109 /* GlucoseSmoothing.swift */, 27850BA4256724EF0020D109 /* GlucoseFromRaw.swift */, 27850BA6256724F00020D109 /* .gitignore */, ); @@ -495,10 +470,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 +513,8 @@ isa = PBXGroup; children = ( 275786AA26753CC400845D0E /* SettingsView.swift */, - 27850D4A25672DFB0020D109 /* SnoozeView.swift */, + B35F953580334AF8B287D85E /* LibreSensorLifecycle+Display.swift */, 2793EBE5260BDE43001A35A3 /* CalibrationEditView.swift */, - 275EC9AA265EDC280043210E /* GlucoseSettingsView.swift */, - 270C1428266047490063405B /* NotificationSettingsView.swift */, - 277773B42639F4E300431547 /* AlarmSettings */, ); path = Settings; sourceTree = ""; @@ -555,12 +525,22 @@ 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 */, + E4B6C1EBA2082D9D5837DDDC /* MeasurementError+Display.swift */, + ); + path = Alerting; + sourceTree = ""; + }; 432B0E7E1CDFC3C50045347B = { isa = PBXGroup; children = ( @@ -595,9 +575,9 @@ children = ( C1C318D42A47637100C6F29F /* Mocks */, 27ED67B62698EEEF003E5DAB /* Observables */, + A8D5D1B79EF34BA3AEC8C642 /* Alerting */, + D1C2C10BD0EA4E83BE81F580 /* LibreCGMManagerState.swift */, 27850D0B25672CA60020D109 /* ConcreteGlucoseDisplayable.swift */, - 27850D0D25672CA60020D109 /* NotificationHelper.swift */, - 271A39F42975844B0005FEDA /* NotificationHelperOverride.swift */, 27850D0C25672CA60020D109 /* LibreGlucose.swift */, B66D1F722E6A813000471149 /* Localizable.xcstrings */, 43A8EC9C210E68CE00A81379 /* LibreTransmitterManagerV3.swift */, @@ -941,6 +921,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 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 */, 27850D9E25672F170020D109 /* UUIDContainer.swift in Sources */, @@ -949,7 +934,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 +962,18 @@ 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 +983,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 274E71D52986D4A600FCFECD /* CriticalAlarmsVolumeView.swift in Sources */, + 3E9EBC7F76A84FFDA055A60D /* LibreSensorLifecycle+Display.swift in Sources */, 2746C74226DD0F8800E31BD9 /* Libre2DirectSetup.swift in Sources */, 27ED67BA26990D6B003E5DAB /* GenericObservableObject.swift in Sources */, 27850CFE25672C0C0020D109 /* HKUnit.swift in Sources */, @@ -1012,30 +991,23 @@ 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 */, - 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..ff68a80 --- /dev/null +++ b/LibreTransmitter/Alerting/LibreTransmitterManagerV3+Alerts.swift @@ -0,0 +1,56 @@ +// +// 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() { + // Serialize evaluation + state mutation to avoid races between delegate callbacks and polling. + delegateQueue.async { [weak self] in + guard let self else { return } + + let currentLifecycle = self.sensorLifecycle + let currentIsPaired = self.isDeviceSelected + DispatchQueue.main.async { + self.sensorInfoObservable.sensorLifecycle = currentLifecycle + self.sensorInfoObservable.isPaired = currentIsPaired + } + + 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 + } + + 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/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/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/LibreGlucose.swift b/LibreTransmitter/LibreGlucose.swift index 43ec73c..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 } @@ -128,15 +129,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 +144,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/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift b/LibreTransmitter/LibreTransmitterManager+Libre2EU.swift index 257f315..ec2757a 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))) @@ -91,12 +84,17 @@ 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 } 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 6c5d7f6..83ad42d 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,7 +31,10 @@ extension LibreTransmitterManagerV3 { } } else { logger.debug("Sensor type was incorrect, and no decryption of sensor was possible") - self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.encryptedSensor)) + self.lastFault = .encryptedOrUnsupported + self.delegateQueue.async { + self.cgmManagerDelegate?.cgmManager(self, hasNew: .error(LibreError.encryptedSensor)) + } return } } @@ -44,8 +45,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,25 +56,29 @@ 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))) - 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 @@ -87,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) @@ -95,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 @@ -127,10 +129,14 @@ 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 + // 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 { @@ -148,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 { @@ -167,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 { @@ -203,14 +204,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 +249,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..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 @@ -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: isDeviceSelected, + activatedAt: sensorInfoObservable.activatedAt, + expiresAt: sensorInfoObservable.expiresAt, + latestReadingAt: latestReadingTimestamp, + sensorMaxMinutesWearTime: sensorInfoObservable.sensorMaxMinutesWearTime, + sensorState: lastKnownSensorState, + lastFault: lastFault + ) } public var glucoseDisplay: GlucoseDisplayable? @@ -167,15 +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? { willSet(newValue) { guard let newValue else { @@ -186,26 +216,12 @@ 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 - } - } - + // 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 } logger.debug("latestBackfill set, newvalue is \(newValue.glucose)") @@ -234,14 +250,42 @@ 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" } + 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://") @@ -254,7 +298,8 @@ open class LibreTransmitterManagerV3: CGMManager, LibreTransmitterDelegate { lastConnected = nil logger.debug("LibreTransmitterManager will be created now") - NotificationHelper.requestNotificationPermissionsIfNeeded() + + sensorInfoObservable.isPaired = isDeviceSelected if isDeviceSelected { establishProxy() @@ -270,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() { @@ -331,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 { @@ -362,6 +396,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" @@ -470,15 +505,7 @@ extension LibreTransmitterManagerV3 { self.logger.debug("will set glucoseInfoObservable") self.glucoseInfoObservable.glucose = d.quantity self.glucoseInfoObservable.date = d.timestamp - } - - if let d = self.latestPrediction { - self.glucoseInfoObservable.prediction = d.quantity - self.glucoseInfoObservable.predictionDate = d.timestamp - - } else { - self.glucoseInfoObservable.prediction = nil - self.glucoseInfoObservable.predictionDate = nil + self.sensorInfoObservable.activeMeasurementErrors = d.error.filter { $0 != .OK } } } } diff --git a/LibreTransmitter/Localizable.xcstrings b/LibreTransmitter/Localizable.xcstrings index 4d46680..a4b2307 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" : { @@ -1761,6 +1764,69 @@ } } }, + "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" + }, + "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/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/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/LibreTransmitter/Observables/SensorInfo.swift b/LibreTransmitter/Observables/SensorInfo.swift index 97bbb47..b81357d 100644 --- a/LibreTransmitter/Observables/SensorInfo.swift +++ b/LibreTransmitter/Observables/SensorInfo.swift @@ -20,7 +20,21 @@ public class SensorInfo: ObservableObject, Equatable, Hashable { @Published public var activatedAt : Date? @Published public var expiresAt : Date? - + + @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 - + /// 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/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/LibreTransmitterManager+UI.swift b/LibreTransmitterUI/LibreTransmitterManager+UI.swift index aa50375..09fe00e 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 @@ -36,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, @@ -49,21 +68,43 @@ extension LibreTransmitterManagerV3: CGMManagerUI { notifyDelete: wantToTerminateNotifier, notifyReset: wantToResetCGMManagerNotifier, notifyReconnect:wantToRestablishConnectionNotifier, - alarmStatus: self.alarmStatus, + 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() @@ -99,7 +140,22 @@ 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 .active: + return nil + case .expired: + 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: + 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 +165,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/Localizable.xcstrings b/LibreTransmitterUI/Localizable.xcstrings index 840774a..6561c10 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" : { @@ -2563,7 +2573,11 @@ } } }, + "Bridge battery low" : { + "comment" : "Title for a warning that the bridge transmitter's battery is low" + }, "Calibration Details" : { + "comment" : "Title for calibration details screen", "localizations" : { "ar" : { "stringUnit" : { @@ -2706,6 +2720,7 @@ } }, "Calibration Edit" : { + "comment" : "Title for calibration edit screen", "localizations" : { "ar" : { "stringUnit" : { @@ -3010,6 +3025,7 @@ }, "Click to Snooze Alerts" : { "comment" : "Text describing click to snooze label in snoozeview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3153,6 +3169,7 @@ }, "Configuration" : { "comment" : "Text describing header for advanced settings section", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3288,6 +3305,9 @@ } } }, + "Connecting" : { + "comment" : "status connecting" + }, "Connection options" : { "comment" : "Text describing options for connecting to sensor or transmitter", "localizations" : { @@ -3431,6 +3451,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", @@ -3588,6 +3611,7 @@ } }, "Critical alarm volume" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3730,6 +3754,7 @@ } }, "Critical alarms will always be sent with volume at minimum 60%" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -4315,6 +4340,7 @@ }, "Debug options" : { "comment" : "Text describing header for debug options in glucosesettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5314,6 +5340,7 @@ } }, "Edit calibrations" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -5455,6 +5482,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" : { @@ -6054,6 +6084,7 @@ }, "Glucose Notification visibility" : { "comment" : "Text describing header for notification visibility in notificationsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6196,6 +6227,7 @@ } }, "Glucose Settings" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6481,6 +6513,7 @@ }, "High" : { "comment" : "Text describing High glucose label in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -6909,6 +6942,7 @@ } }, "Invalid sensor" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -7193,149 +7227,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" : { @@ -8065,6 +7956,7 @@ }, "Low" : { "comment" : "Text describing Low glucose label in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -8207,6 +8099,7 @@ } }, "Low battery" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -8348,6 +8241,9 @@ } } }, + "Manage" : { + "comment" : "Text describing header for manage section" + }, "mg/dL" : { "comment" : "The short unit display string for milligrams of glucose per decilter", "localizations" : { @@ -9207,6 +9103,7 @@ }, "No Connection: " : { "comment" : "Text describing no connection label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9348,7 +9245,11 @@ } } }, + "No Sensor" : { + "comment" : "status no sensor" + }, "Notification" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -9492,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" : { @@ -9789,6 +9691,9 @@ } } }, + "Pair a sensor to start receiving readings." : { + "comment" : "msg no sensor" + }, "Pair Sensor" : { "comment" : "Button title for pairing sensor", "localizations" : { @@ -10233,6 +10138,7 @@ }, "Pause Glucose alarms" : { "comment" : "Text for pausing glucose alarms", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10375,6 +10281,7 @@ } }, "Persist sensordata" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10804,6 +10711,7 @@ }, "Remote data storage" : { "comment" : "Text describing header for remote data storage", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -10945,7 +10853,11 @@ } } }, + "Replace\nSensor" : { + "comment" : "Status highlight message for a failed sensor" + }, "Replace sensor immediately to continue receving glucose values" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11226,6 +11138,7 @@ }, "Schedule " : { "comment" : "Text describing schedule in alarmsettingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11653,7 +11566,17 @@ } } }, + "Sensor\nExpired" : { + "comment" : "Status highlight message for expired sensor" + }, + "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" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -11795,6 +11718,9 @@ } } }, + "Sensor Expired" : { + "comment" : "status expired" + }, "Sensor expires in " : { "comment" : "Text describing sensor expires in label in settingsview", "localizations" : { @@ -11939,6 +11865,7 @@ } }, "Sensor expires soon" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12080,7 +12007,11 @@ } } }, + "Sensor Information" : { + "comment" : "Text describing header for sensor information section" + }, "Sensor is expired" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12222,7 +12153,17 @@ } } }, + "Sensor Issues" : { + "comment" : "status measurement issue" + }, + "Sensor Malfunction" : { + "comment" : "status malfunction" + }, + "Sensor Not Detected" : { + "comment" : "status sensor not activated" + }, "Sensor not found" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12364,8 +12305,12 @@ } } }, + "Sensor OK" : { + "comment" : "status ok" + }, "Sensor Serial" : { "comment" : "Text describing Sensor serial label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12652,6 +12597,7 @@ }, "Sensor State" : { "comment" : "Text describing Sensor state label in settingsview", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -12949,7 +12895,17 @@ } } }, + "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" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13379,6 +13335,7 @@ }, "To " : { "comment" : "Very short text describing separation between start and end datetimes", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -13820,6 +13777,7 @@ } }, "Upload to remote data service" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14417,6 +14375,7 @@ } }, "Value" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14559,6 +14518,7 @@ } }, "View factory calibrations" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -14700,6 +14660,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" : { 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/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/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..f0dc35c --- /dev/null +++ b/LibreTransmitterUI/Views/Settings/LibreSensorLifecycle+Display.swift @@ -0,0 +1,158 @@ +// +// 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 { + case noSensor + case connecting + case ok + case warmingUp + case expired + 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 } + + var severity: Severity { + switch self { + case .connecting, .noSensor, .warmingUp: return .neutral + case .ok: return .good + case .signalLost, .measurementIssue: 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" + case .measurementIssue: return "exclamationmark.triangle" + } + } + + 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") + case .measurementIssue: return Text("Sensor Issues", comment: "status measurement issue") + } + } + + 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" + ) + case let .measurementIssue(errors): + return Text(errors.map(\.localizedDescription).joined(separator: "\n")) + } + } + + /// Combines the pairing/data-driven `LibreSensorLifecycle` with a live BLE + /// connection flag - and, critically, with `isDeviceSelected` checked + /// *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`); 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, + measurementErrors: [MeasurementError] = [] + ) -> 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: + let issues = measurementErrors.filter { $0 != .OK } + return issues.isEmpty ? .ok : .measurementIssue(issues) + case .expired: + return .expired + case .signalLost: + return .signalLost + case .failed: + return .malfunction + case .unactivated: + return .notActivated + } + } +} 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..bfd4203 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() @@ -67,9 +68,10 @@ 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? - @ObservedObject var alarmStatus: LibreTransmitter.AlarmStatus @State private var showingDestructQuestion = false // @State private var showingExporter = false @@ -86,7 +88,8 @@ struct SettingsView: View { notifyDelete: GenericObservableObject, notifyReset: GenericObservableObject, notifyReconnect: GenericObservableObject, - alarmStatus: LibreTransmitter.AlarmStatus, + notifyShowDeviceDetails: GenericObservableObject, + notifyShowCalibrations: GenericObservableObject, pairingService: SensorPairingProtocol, bluetoothSearcher: BluetoothSearcher) { @@ -97,45 +100,28 @@ struct SettingsView: View { self.notifyDelete = notifyDelete self.notifyReset = notifyReset self.notifyReconnect = notifyReconnect - self.alarmStatus = alarmStatus + self.notifyShowDeviceDetails = notifyShowDeviceDetails + self.notifyShowCalibrations = notifyShowCalibrations self.pairingService = pairingService self.bluetoothSearcher = bluetoothSearcher } - private var glucoseUnit: HKUnit { - displayGlucosePreference.unit - } - 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 - 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"))) { - SettingsItem(title: "CurrentBG", detail: displayGlucosePreference.format(prediction)) - SettingsItem(title: "Date", detail: longDateFormatter.string(from: date) ) - } - } - - NavigationLink(destination: deviceInfoSection) { - SettingsItem(title: "Device details") - } - - NavigationLink(destination: CalibrationEditView()) { - Button(Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { - print("edit calibration clicked") - } + + sensorInfoSection + + disclosureButton(title: "Device details") { + notifyShowDeviceDetails.notify() } - advancedSection - sensorChangeSection + + manageSection destructSection - + }.listStyle(InsetGroupedListStyle()) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -144,17 +130,20 @@ 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) - + /// 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 { @@ -174,42 +163,13 @@ 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 ) - } - - SettingsItem(title: "Sensor Type", detail: $transmitterInfo.sensorType ) - - SettingsItem(title: "Sensor Start", detail: sensorInfo.activatedAtString ) - SettingsItem(title: "Sensor End", detail: sensorInfo.expiresAtString ) - } + 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 ) } - .textSelection(.enabled) } private var doneButton: some View { @@ -218,12 +178,13 @@ struct SettingsView: View { }) } - var sensorChangeSection: some View { - - Section { + var manageSection: some View { + Section(header: Text(LocalizedString("Manage", comment: "Text describing header for manage section"))) { + disclosureButton(title: Features.allowsEditingFactoryCalibrationData ? "Edit calibrations" : "View factory calibrations") { + notifyShowCalibrations.notify() + } + NavigationLink(destination: AuthView(completeNotifier: notifyComplete, notifyReset: notifyReset, notifyReconnect: notifyReconnect, pairingService: pairingService, bluetoothSearcher: bluetoothSearcher)) { - /*Button("Change Sensor") { - }.foregroundColor(.blue)*/ SettingsItem(title: "Change Sensor").foregroundColor(.blue) } } @@ -257,31 +218,6 @@ 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") - } - - } - } - private var daysRemaining: Int? { if let remaining = sensorInfo.expiresAt?.timeIntervalSinceNow, remaining > .days(1) { return Int(remaining.days) @@ -314,14 +250,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 { @@ -385,84 +313,106 @@ struct SettingsView: View { }.frame(maxWidth: .infinity) } - var sensorStatusText : String { - let ret = sensorInfo.sensorState - return ret.isEmpty ? " - " : ret + private var isConnected: Bool { + [BluetoothmanagerState.Connected, .Notifying].map(\.rawValue).contains(transmitterInfo.connectionState) } - 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 sensorStatusRow: some View { + 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)) + VStack(alignment: .leading, spacing: 2) { + status.title.fontWeight(.heavy).foregroundStyle(.primary) + status.message.foregroundStyle(.secondary) + } } } - - var sensorSerialText : String { - let ret = sensorInfo.sensorSerial - print("got serial: \(ret)") - return ret.isEmpty ? " - " : ret + + // 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 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 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) { - + 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 + if isBridgeBatteryLow { + bridgeBatteryRow } } } } -struct SettingsOverview_Previews: PreviewProvider { - static var previews: some View { - NotificationSettingsView() +/// 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/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/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 + } } 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 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