Alarm.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. //
  2. // Alarm.swift
  3. // LoopFollow
  4. //
  5. // Created by Jonas Björkert on 2025-03-15.
  6. // Copyright © 2025 Jon Fawcett. All rights reserved.
  7. //
  8. import Foundation
  9. import HealthKit
  10. import UserNotifications
  11. protocol DayNightDisplayable {
  12. var displayName: String { get }
  13. }
  14. extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
  15. var displayName: String {
  16. rawValue == "always" ? "Day & Night" : rawValue.capitalized
  17. }
  18. }
  19. enum PlaySoundOption: String, CaseIterable, Codable, DayNightDisplayable {
  20. case always, day, night, never
  21. }
  22. enum RepeatSoundOption: String, CaseIterable, Codable, DayNightDisplayable {
  23. case always, day, night, never
  24. }
  25. enum ActiveOption: String, CaseIterable, Codable, DayNightDisplayable {
  26. case always, day, night
  27. }
  28. struct Alarm: Identifiable, Codable, Equatable {
  29. var id: UUID = UUID()
  30. var type: AlarmType
  31. /// Name of the alarm, defaults to alarm type
  32. var name: String
  33. var isEnabled: Bool = true
  34. /// If the alarm is manually snoozed, we store the end time for the snooze here
  35. var snoozedUntil: Date?
  36. /// Alarm threashold, it can be a bgvalue (in mg/Dl), or day for example
  37. /// Also used as bg limit for drop alarms for example
  38. var threshold: Double?
  39. /// If the alarm looks at predictions, this is how long into the future to look
  40. var predictiveMinutes: Int?
  41. /// If the alarm acts on delta, the delta is stored here, it can be a delta bgvalue (in mg/Dl)
  42. /// If a delta alarm is only active below a bg, that bg is stored in threshold
  43. var delta: Double?
  44. /// Number of minutes that must satisfy the alarm criteria
  45. var persistentMinutes: Int?
  46. /// Size of window to observe values, for example battery drop of x within this number of minutes,
  47. var monitoringWindow: Int?
  48. var soundFile: SoundFile
  49. /// Snooze duration, it can be minutes, days or hours. Stepping is different per alarm type
  50. var snoozeDuration: Int = 5
  51. /// When the alarm should play it's sound
  52. var playSoundOption: PlaySoundOption = .always
  53. /// When the sound should repeat
  54. var repeatSoundOption: RepeatSoundOption = .always
  55. /// When is the alarm active
  56. var activeOption: ActiveOption = .always
  57. /// For temporary alerts, it will trigger once and then disable itself
  58. var disableAfterFiring: Bool = false
  59. // ─────────────────────────────────────────────────────────────
  60. // Missed‑Bolus‑specific settings
  61. // ─────────────────────────────────────────────────────────────
  62. /// “Prebolus Max Time” (if a bolus comes within this many minutes *before* the carbs, treat it as prebolus)
  63. var missedBolusPrebolusWindow: Int?
  64. /// “Ignore Bolus <= X units” (don’t count any bolus smaller than or equal to this)
  65. var missedBolusIgnoreSmallBolusUnits: Double?
  66. /// “Ignore Under Grams” (if carb entry is under this many grams, skip the alert)
  67. var missedBolusIgnoreUnderGrams: Double?
  68. /// “Ignore Under BG” (if current BG is below this, skip the alert)
  69. var missedBolusIgnoreUnderBG: Double?
  70. // ─────────────────────────────────────────────────────────────
  71. // Bolus‑Count fields ─
  72. // ─────────────────────────────────────────────────────────────
  73. /// trigger when N or more of those boluses occur...
  74. var bolusCountThreshold: Int?
  75. /// ...within this many minutes
  76. var bolusWindowMinutes: Int?
  77. func checkCondition(data: AlarmData) -> Bool {
  78. return false
  79. }
  80. /// Function for when the alarm is triggered.
  81. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification.
  82. func trigger(config : AlarmConfiguration, now: Date) {
  83. LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)")
  84. var playSound: Bool = true
  85. // Global mute
  86. if let until = config.muteUntil, until > now {
  87. playSound = false
  88. }
  89. // Mute during calls
  90. if !config.audioDuringCalls && isOnPhoneCall() {
  91. playSound = false
  92. }
  93. // Mute this alarm day or night or always
  94. let cal = Calendar.current
  95. let today = cal.startOfDay(for: now)
  96. let dayStart = cal.date(bySettingHour: config.dayStart.hour,
  97. minute: config.dayStart.minute,
  98. second: 0,
  99. of: today)!
  100. let nightStart = cal.date(bySettingHour: config.nightStart.hour,
  101. minute: config.nightStart.minute,
  102. second: 0,
  103. of: today)!
  104. let isNight: Bool
  105. if nightStart >= dayStart {
  106. isNight = (now >= nightStart) || (now < dayStart)
  107. } else {
  108. isNight = (now >= nightStart) && (now < dayStart)
  109. }
  110. let isDay = !isNight
  111. switch playSoundOption {
  112. case .always:
  113. break
  114. case .never:
  115. playSound = false
  116. case .day where !isDay:
  117. playSound = false
  118. case .night where !isNight:
  119. playSound = false
  120. default:
  121. break
  122. }
  123. let shouldRepeat: Bool = {
  124. switch repeatSoundOption {
  125. case .always: return true
  126. case .never: return false
  127. case .day: return isDay
  128. case .night: return isNight
  129. }
  130. }()
  131. UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
  132. let content = UNMutableNotificationContent()
  133. content.title = type.rawValue
  134. content.subtitle += Observable.shared.bgText.value + " "
  135. content.subtitle += Observable.shared.directionText.value + " "
  136. content.subtitle += Observable.shared.deltaText.value
  137. content.categoryIdentifier = "category"
  138. // This is needed to trigger vibrate on watch and phone
  139. // See if we can use .Critcal
  140. // See if we should use this method instead of direct sound player
  141. content.sound = .default
  142. let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
  143. let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)
  144. UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
  145. let action = UNNotificationAction(identifier: "snooze", title: "Snooze", options: [])
  146. let category = UNNotificationCategory(identifier: "category", actions: [action], intentIdentifiers: [], options: [])
  147. UNUserNotificationCenter.current().setNotificationCategories([category])
  148. /* TODO när vi gör bg alarm sätt timestamp/datum för denna readings tid så vi inte larmar på samma igen, se isBGBased
  149. if snooozedBGReadingTime != nil {
  150. UserDefaultsRepository.snoozedBGReadingTime.value = snooozedBGReadingTime
  151. }
  152. */
  153. if playSound {
  154. AlarmSound.setSoundFile(str: self.soundFile.rawValue)
  155. AlarmSound.play(repeating: shouldRepeat)
  156. }
  157. }
  158. init(type: AlarmType) {
  159. self.type = type
  160. self.name = type.rawValue
  161. switch type {
  162. case .buildExpire:
  163. /// Alert 7 days before the build expires
  164. self.threshold = 7
  165. self.soundFile = .wrongAnswer
  166. self.snoozeDuration = 1
  167. self.repeatSoundOption = .always
  168. case .low:
  169. soundFile = .indeed
  170. case .iob:
  171. soundFile = .alertToneRingtone1
  172. case .bolus:
  173. soundFile = .dholShuffleloop
  174. case .cob:
  175. soundFile = .alertToneRingtone2
  176. case .high:
  177. soundFile = .timeHasCome
  178. case .fastDrop:
  179. soundFile = .bigClockTicking
  180. case .fastRise:
  181. soundFile = .cartoonFailStringsTrumpet
  182. case .missedReading:
  183. soundFile = .cartoonTipToeSneakyWalk
  184. case .notLooping:
  185. soundFile = .sciFiEngineShutDown
  186. case .missedBolus:
  187. soundFile = .dholShuffleloop
  188. case .sensorChange:
  189. soundFile = .wakeUpWillYou
  190. case .pumpChange:
  191. soundFile = .wakeUpWillYou
  192. case .pump:
  193. soundFile = .marimbaDescend
  194. case .battery:
  195. soundFile = .machineCharge
  196. case .batteryDrop:
  197. soundFile = .machineCharge
  198. case .recBolus:
  199. soundFile = .dholShuffleloop
  200. case .overrideStart:
  201. soundFile = .endingReached
  202. case .overrideEnd:
  203. soundFile = .alertToneBusy
  204. case .tempTargetStart:
  205. soundFile = .endingReached
  206. case .tempTargetEnd:
  207. soundFile = .alertToneBusy
  208. }
  209. }
  210. }
  211. extension AlarmType {
  212. enum Group: String, CaseIterable {
  213. case glucose = "Glucose"
  214. case insulin = "Insulin / Food"
  215. case device = "Device / System"
  216. case other = "Override / Target"
  217. }
  218. var group: Group {
  219. switch self {
  220. case .low, .high, .fastDrop, .fastRise, .missedReading:
  221. return .glucose
  222. case .iob, .bolus, .cob, .missedBolus, .recBolus:
  223. return .insulin
  224. case .battery, .batteryDrop, .pump, .pumpChange,
  225. .sensorChange, .notLooping, .buildExpire:
  226. return .device
  227. default:
  228. return .other
  229. }
  230. }
  231. var icon: String {
  232. switch self {
  233. case .low : return "arrow.down.to.line"
  234. case .high : return "arrow.up.to.line"
  235. case .fastDrop : return "chevron.down.2"
  236. case .fastRise : return "chevron.up.2"
  237. case .missedReading: return "wifi.slash"
  238. case .iob, .bolus: return "syringe"
  239. case .cob : return "fork.knife"
  240. case .missedBolus: return "exclamationmark.arrow.triangle.2.circlepath"
  241. case .recBolus : return "bolt.horizontal"
  242. case .battery: return "battery.25"
  243. case .batteryDrop: return "battery.100.bolt"
  244. case .pump: return "drop"
  245. case .pumpChange: return "arrow.triangle.2.circlepath"
  246. case .sensorChange: return "sensor.tag.radiowaves.forward"
  247. case .notLooping: return "circle.slash"
  248. case .buildExpire: return "calendar.badge.exclamationmark"
  249. case .overrideStart: return "play.circle"
  250. case .overrideEnd: return "stop.circle"
  251. case .tempTargetStart: return "flag"
  252. case .tempTargetEnd: return "flag.slash"
  253. }
  254. }
  255. var blurb: String {
  256. switch self {
  257. case .low: return "Alerts when BG goes below a limit."
  258. case .high: return "Alerts when BG rises above a limit."
  259. case .fastDrop: return "Rapid downward BG trend."
  260. case .fastRise: return "Rapid upward BG trend."
  261. case .missedReading: return "No CGM data for X minutes."
  262. case .iob: return "High insulin-on-board."
  263. case .bolus: return "Large individual bolus."
  264. case .cob: return "High carbs-on-board."
  265. case .missedBolus: return "Carbs without bolus."
  266. case .recBolus: return "Recommended bolus issued."
  267. case .battery: return "Phone battery low."
  268. case .batteryDrop: return "Battery drops quickly."
  269. case .pump: return "Reservoir level low."
  270. case .pumpChange: return "Pump change due."
  271. case .sensorChange: return "Sensor change due."
  272. case .notLooping: return "Loop hasn’t completed."
  273. case .buildExpire: return "Looping-app build expiring."
  274. case .overrideStart: return "Override just started."
  275. case .overrideEnd: return "Override ended."
  276. case .tempTargetStart:return "Temp target started."
  277. case .tempTargetEnd: return "Temp target ended."
  278. }
  279. }
  280. }