Alarm.swift 13 KB

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