HealthKitManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. import Combine
  2. import Foundation
  3. import HealthKit
  4. import LoopKit
  5. import LoopKitUI
  6. import Swinject
  7. protocol HealthKitManager: GlucoseSource {
  8. /// Check all needed permissions
  9. /// Return false if one or more permissions are deny or not choosen
  10. var areAllowAllPermissions: Bool { get }
  11. /// Check availability to save data of BG type to Health store
  12. func checkAvailabilitySaveBG() -> Bool
  13. /// Requests user to give permissions on using HealthKit
  14. func requestPermission(completion: ((Bool, Error?) -> Void)?)
  15. /// Save blood glucose to Health store (dublicate of bg will ignore)
  16. func saveIfNeeded(bloodGlucose: [BloodGlucose])
  17. /// Save carbs to Health store (dublicate of bg will ignore)
  18. func saveIfNeeded(carbs: [CarbsEntry])
  19. /// Save Insulin to Health store
  20. func saveIfNeeded(pumpEvents events: [PumpHistoryEvent])
  21. /// Create observer for data passing beetwen Health Store and FreeAPS
  22. func createBGObserver()
  23. /// Enable background delivering objects from Apple Health to FreeAPS
  24. func enableBackgroundDelivery()
  25. /// Delete glucose with syncID
  26. func deleteGlucose(syncID: String)
  27. /// delete carbs with syncID
  28. func deleteCarbs(syncID: String, isFPU: Bool?, fpuID: String?)
  29. /// delete insulin with syncID
  30. func deleteInsulin(syncID: String)
  31. }
  32. final class BaseHealthKitManager: HealthKitManager, Injectable, CarbsObserver {
  33. private enum Config {
  34. // unwraped HKObjects
  35. static var readPermissions: Set<HKSampleType> {
  36. Set([healthBGObject].compactMap { $0 }) }
  37. static var writePermissions: Set<HKSampleType> {
  38. Set([healthBGObject, healthCarbObject, healthInsulinObject].compactMap { $0 }) }
  39. // link to object in HealthKit
  40. static let healthBGObject = HKObjectType.quantityType(forIdentifier: .bloodGlucose)
  41. static let healthCarbObject = HKObjectType.quantityType(forIdentifier: .dietaryCarbohydrates)
  42. static let healthInsulinObject = HKObjectType.quantityType(forIdentifier: .insulinDelivery)
  43. // Meta-data key of FreeASPX data in HealthStore
  44. static let freeAPSMetaKey = "fromFreeAPSX"
  45. }
  46. @Injected() private var glucoseStorage: GlucoseStorage!
  47. @Injected() private var healthKitStore: HKHealthStore!
  48. @Injected() private var settingsManager: SettingsManager!
  49. @Injected() private var broadcaster: Broadcaster!
  50. @Injected() var carbsStorage: CarbsStorage!
  51. private let processQueue = DispatchQueue(label: "BaseHealthKitManager.processQueue")
  52. private var lifetime = Lifetime()
  53. // BG that will be return Publisher
  54. @SyncAccess @Persisted(key: "BaseHealthKitManager.newGlucose") private var newGlucose: [BloodGlucose] = []
  55. // last anchor for HKAnchoredQuery
  56. private var lastBloodGlucoseQueryAnchor: HKQueryAnchor? {
  57. set {
  58. persistedBGAnchor = try? NSKeyedArchiver.archivedData(withRootObject: newValue as Any, requiringSecureCoding: false)
  59. }
  60. get {
  61. guard let data = persistedBGAnchor else { return nil }
  62. return try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? HKQueryAnchor
  63. }
  64. }
  65. @Persisted(key: "HealthKitManagerAnchor") private var persistedBGAnchor: Data? = nil
  66. var isAvailableOnCurrentDevice: Bool {
  67. HKHealthStore.isHealthDataAvailable()
  68. }
  69. var areAllowAllPermissions: Bool {
  70. Set(Config.readPermissions.map { healthKitStore.authorizationStatus(for: $0) })
  71. .intersection([.notDetermined])
  72. .isEmpty &&
  73. Set(Config.writePermissions.map { healthKitStore.authorizationStatus(for: $0) })
  74. .intersection([.sharingDenied, .notDetermined])
  75. .isEmpty
  76. }
  77. // NSPredicate, which use during load increment BG from Health store
  78. private var loadBGPredicate: NSPredicate {
  79. // loading only daily bg
  80. let predicateByStartDate = HKQuery.predicateForSamples(
  81. withStart: Date().addingTimeInterval(-1.days.timeInterval),
  82. end: nil,
  83. options: .strictStartDate
  84. )
  85. // loading only not FreeAPS bg
  86. // this predicate dont influence on Deleted Objects, only on added
  87. let predicateByMeta = HKQuery.predicateForObjects(
  88. withMetadataKey: Config.freeAPSMetaKey,
  89. operatorType: .notEqualTo,
  90. value: 1
  91. )
  92. return NSCompoundPredicate(andPredicateWithSubpredicates: [predicateByStartDate, predicateByMeta])
  93. }
  94. init(resolver: Resolver) {
  95. injectServices(resolver)
  96. guard isAvailableOnCurrentDevice,
  97. Config.healthBGObject != nil else { return }
  98. createBGObserver()
  99. enableBackgroundDelivery()
  100. broadcaster.register(CarbsObserver.self, observer: self)
  101. debug(.service, "HealthKitManager did create")
  102. }
  103. func checkAvailabilitySave(objectTypeToHealthStore: HKObjectType) -> Bool {
  104. healthKitStore.authorizationStatus(for: objectTypeToHealthStore) == .sharingAuthorized
  105. }
  106. func checkAvailabilitySaveBG() -> Bool {
  107. Config.healthBGObject.map { checkAvailabilitySave(objectTypeToHealthStore: $0) } ?? false
  108. }
  109. func requestPermission(completion: ((Bool, Error?) -> Void)? = nil) {
  110. guard isAvailableOnCurrentDevice else {
  111. completion?(false, HKError.notAvailableOnCurrentDevice)
  112. return
  113. }
  114. guard Config.readPermissions.isNotEmpty, Config.writePermissions.isNotEmpty else {
  115. completion?(false, HKError.dataNotAvailable)
  116. return
  117. }
  118. healthKitStore.requestAuthorization(toShare: Config.writePermissions, read: Config.readPermissions) { status, error in
  119. completion?(status, error)
  120. }
  121. }
  122. func saveIfNeeded(bloodGlucose: [BloodGlucose]) {
  123. guard settingsManager.settings.useAppleHealth,
  124. let sampleType = Config.healthBGObject,
  125. checkAvailabilitySave(objectTypeToHealthStore: sampleType),
  126. bloodGlucose.isNotEmpty
  127. else { return }
  128. func save(samples: [HKSample]) {
  129. let sampleIDs = samples.compactMap(\.syncIdentifier)
  130. let samplesToSave = bloodGlucose
  131. .filter { !sampleIDs.contains($0.id) }
  132. .map {
  133. HKQuantitySample(
  134. type: sampleType,
  135. quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double($0.glucose!)),
  136. start: $0.dateString,
  137. end: $0.dateString,
  138. metadata: [
  139. HKMetadataKeyExternalUUID: $0.id,
  140. HKMetadataKeySyncIdentifier: $0.id,
  141. HKMetadataKeySyncVersion: 1,
  142. Config.freeAPSMetaKey: true
  143. ]
  144. )
  145. }
  146. healthKitStore.save(samplesToSave) { _, _ in }
  147. }
  148. loadSamplesFromHealth(sampleType: sampleType, withIDs: bloodGlucose.map(\.id))
  149. .receive(on: processQueue)
  150. .sink(receiveValue: save)
  151. .store(in: &lifetime)
  152. }
  153. func saveIfNeeded(carbs: [CarbsEntry]) {
  154. guard settingsManager.settings.useAppleHealth,
  155. let sampleType = Config.healthCarbObject,
  156. checkAvailabilitySave(objectTypeToHealthStore: sampleType),
  157. carbs.isNotEmpty
  158. else { return }
  159. let carbsWithId = carbs.filter { c in
  160. guard c.id != nil else { return false }
  161. return true
  162. }
  163. func save(samples: [HKSample]) {
  164. let sampleIDs = samples.compactMap(\.syncIdentifier)
  165. let sampleDates = samples.map(\.startDate)
  166. let samplesToSave = carbsWithId
  167. .filter { !sampleIDs.contains($0.id!) } // id existing in AH
  168. .filter { !sampleDates.contains($0.createdAt) } // not id but exaclty the same datetime
  169. .map {
  170. HKQuantitySample(
  171. type: sampleType,
  172. quantity: HKQuantity(unit: .gram(), doubleValue: Double($0.carbs)),
  173. start: $0.createdAt,
  174. end: $0.createdAt,
  175. metadata: [
  176. HKMetadataKeyExternalUUID: $0.id ?? "_id",
  177. HKMetadataKeySyncIdentifier: $0.id ?? "_id",
  178. HKMetadataKeySyncVersion: 1,
  179. Config.freeAPSMetaKey: true
  180. ]
  181. )
  182. }
  183. healthKitStore.save(samplesToSave) { _, _ in }
  184. }
  185. loadSamplesFromHealth(sampleType: sampleType)
  186. .receive(on: processQueue)
  187. .sink(receiveValue: save)
  188. .store(in: &lifetime)
  189. }
  190. func saveIfNeeded(pumpEvents events: [PumpHistoryEvent]) {
  191. guard settingsManager.settings.useAppleHealth,
  192. let sampleType = Config.healthInsulinObject,
  193. checkAvailabilitySave(objectTypeToHealthStore: sampleType),
  194. events.isNotEmpty
  195. else { return }
  196. func save(bolus: [InsulinBolus], basal: [InsulinBasal]) {
  197. let bolusSamples = bolus
  198. .map {
  199. HKQuantitySample(
  200. type: sampleType,
  201. quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double($0.amount)),
  202. start: $0.date,
  203. end: $0.date,
  204. metadata: [
  205. HKMetadataKeyInsulinDeliveryReason: NSNumber(2),
  206. HKMetadataKeyExternalUUID: $0.id,
  207. HKMetadataKeySyncIdentifier: $0.id,
  208. HKMetadataKeySyncVersion: 1,
  209. Config.freeAPSMetaKey: true
  210. ]
  211. )
  212. }
  213. let basalSamples = basal
  214. .map {
  215. HKQuantitySample(
  216. type: sampleType,
  217. quantity: HKQuantity(unit: .internationalUnit(), doubleValue: Double($0.amount)),
  218. start: $0.startDelivery,
  219. end: $0.endDelivery,
  220. metadata: [
  221. HKMetadataKeyInsulinDeliveryReason: NSNumber(1),
  222. HKMetadataKeyExternalUUID: $0.id,
  223. HKMetadataKeySyncIdentifier: $0.id,
  224. HKMetadataKeySyncVersion: 1,
  225. Config.freeAPSMetaKey: true
  226. ]
  227. )
  228. }
  229. healthKitStore.save(bolusSamples + basalSamples) { _, _ in }
  230. }
  231. loadSamplesFromHealth(sampleType: sampleType, withIDs: events.map(\.id))
  232. .receive(on: processQueue)
  233. .compactMap { samples -> ([InsulinBolus], [InsulinBasal]) in
  234. let sampleIDs = samples.compactMap(\.syncIdentifier)
  235. let bolus = events
  236. .filter { $0.type == .bolus && !sampleIDs.contains($0.id) }
  237. .compactMap { event -> InsulinBolus? in
  238. guard let amount = event.amount else { return nil }
  239. return InsulinBolus(id: event.id, amount: amount, date: event.timestamp)
  240. }
  241. let basalEvents = events
  242. .filter { $0.type == .tempBasal && !sampleIDs.contains($0.id) }
  243. let basal = basalEvents.enumerated()
  244. .compactMap { item -> InsulinBasal? in
  245. let nextElementEventIndex = item.offset + 1
  246. guard basalEvents.count > nextElementEventIndex else { return nil }
  247. let nextBasalEvent = basalEvents[nextElementEventIndex]
  248. let secondsOfCurrentBasal = nextBasalEvent.timestamp.timeIntervalSince(item.element.timestamp)
  249. let amount = Decimal(secondsOfCurrentBasal / 3600) * (item.element.rate ?? 0)
  250. let id = String(item.element.id.dropFirst())
  251. guard amount > 0,
  252. id != ""
  253. else { return nil }
  254. return InsulinBasal(
  255. id: id,
  256. amount: amount,
  257. startDelivery: item.element.timestamp,
  258. endDelivery: nextBasalEvent.timestamp
  259. )
  260. }
  261. return (bolus, basal)
  262. }
  263. .sink(receiveValue: save)
  264. .store(in: &lifetime)
  265. }
  266. func createBGObserver() {
  267. guard settingsManager.settings.useAppleHealth else { return }
  268. guard let bgType = Config.healthBGObject else {
  269. warning(.service, "Can not create HealthKit Observer, because unable to get the Blood Glucose type")
  270. return
  271. }
  272. let query = HKObserverQuery(sampleType: bgType, predicate: nil) { [weak self] _, _, observerError in
  273. guard let self = self else { return }
  274. debug(.service, "Execute HelathKit observer query for loading increment samples")
  275. guard observerError == nil else {
  276. warning(.service, "Error during execution of HelathKit Observer's query", error: observerError!)
  277. return
  278. }
  279. if let incrementQuery = self.getBloodGlucoseHKQuery(predicate: self.loadBGPredicate) {
  280. debug(.service, "Create increment query")
  281. self.healthKitStore.execute(incrementQuery)
  282. }
  283. }
  284. healthKitStore.execute(query)
  285. debug(.service, "Create Observer for Blood Glucose")
  286. }
  287. func enableBackgroundDelivery() {
  288. guard settingsManager.settings.useAppleHealth else {
  289. healthKitStore.disableAllBackgroundDelivery { _, _ in }
  290. return }
  291. guard let bgType = Config.healthBGObject else {
  292. warning(
  293. .service,
  294. "Can not create background delivery, because unable to get the Blood Glucose type"
  295. )
  296. return
  297. }
  298. healthKitStore.enableBackgroundDelivery(for: bgType, frequency: .immediate) { status, error in
  299. guard error == nil else {
  300. warning(.service, "Can not enable background delivery", error: error)
  301. return
  302. }
  303. debug(.service, "Background delivery status is \(status)")
  304. }
  305. }
  306. /// Try to load samples from Health store
  307. private func loadSamplesFromHealth(
  308. sampleType: HKQuantityType
  309. ) -> Future<[HKSample], Never> {
  310. Future { promise in
  311. let query = HKSampleQuery(
  312. sampleType: sampleType,
  313. predicate: nil,
  314. limit: 1000,
  315. sortDescriptors: nil
  316. ) { _, results, _ in
  317. promise(.success((results as? [HKQuantitySample]) ?? []))
  318. }
  319. self.healthKitStore.execute(query)
  320. }
  321. }
  322. /// Try to load samples from Health store with id and do some work
  323. private func loadSamplesFromHealth(
  324. sampleType: HKQuantityType,
  325. withIDs ids: [String]
  326. ) -> Future<[HKSample], Never> {
  327. Future { promise in
  328. let predicate = HKQuery.predicateForObjects(
  329. withMetadataKey: HKMetadataKeySyncIdentifier,
  330. allowedValues: ids
  331. )
  332. let query = HKSampleQuery(
  333. sampleType: sampleType,
  334. predicate: predicate,
  335. limit: 1000,
  336. sortDescriptors: nil
  337. ) { _, results, _ in
  338. promise(.success((results as? [HKQuantitySample]) ?? []))
  339. }
  340. self.healthKitStore.execute(query)
  341. }
  342. }
  343. private func getBloodGlucoseHKQuery(predicate: NSPredicate) -> HKQuery? {
  344. guard let sampleType = Config.healthBGObject else { return nil }
  345. let query = HKAnchoredObjectQuery(
  346. type: sampleType,
  347. predicate: predicate,
  348. anchor: lastBloodGlucoseQueryAnchor,
  349. limit: HKObjectQueryNoLimit
  350. ) { [weak self] _, addedObjects, _, anchor, _ in
  351. guard let self = self else { return }
  352. self.processQueue.async {
  353. debug(.service, "AnchoredQuery did execute")
  354. self.lastBloodGlucoseQueryAnchor = anchor
  355. // Added objects
  356. if let bgSamples = addedObjects as? [HKQuantitySample],
  357. bgSamples.isNotEmpty
  358. {
  359. self.prepareBGSamplesToPublisherFetch(bgSamples)
  360. }
  361. }
  362. }
  363. return query
  364. }
  365. private func prepareBGSamplesToPublisherFetch(_ samples: [HKQuantitySample]) {
  366. dispatchPrecondition(condition: .onQueue(processQueue))
  367. debug(.service, "Start preparing samples: \(String(describing: samples))")
  368. newGlucose += samples
  369. .compactMap { sample -> HealthKitSample? in
  370. let fromFAX = sample.metadata?[Config.freeAPSMetaKey] as? Bool ?? false
  371. guard !fromFAX else { return nil }
  372. return HealthKitSample(
  373. healthKitId: sample.uuid.uuidString,
  374. date: sample.startDate,
  375. glucose: Int(round(sample.quantity.doubleValue(for: .milligramsPerDeciliter)))
  376. )
  377. }
  378. .map { sample in
  379. BloodGlucose(
  380. _id: sample.healthKitId,
  381. sgv: sample.glucose,
  382. direction: nil,
  383. date: Decimal(Int(sample.date.timeIntervalSince1970) * 1000),
  384. dateString: sample.date,
  385. unfiltered: nil,
  386. filtered: nil,
  387. noise: nil,
  388. glucose: sample.glucose,
  389. type: "sgv"
  390. )
  391. }
  392. .filter { $0.dateString >= Date().addingTimeInterval(-1.days.timeInterval) }
  393. newGlucose = newGlucose.removeDublicates()
  394. debug(
  395. .service,
  396. "Current BloodGlucose.Type objects will be send from Publisher during fetch: \(String(describing: newGlucose))"
  397. )
  398. }
  399. // MARK: - GlucoseSource
  400. var glucoseManager: FetchGlucoseManager?
  401. var cgmManager: CGMManagerUI?
  402. var cgmType: CGMType = .nightscout
  403. func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
  404. Future { [weak self] promise in
  405. guard let self = self else {
  406. promise(.success([]))
  407. return
  408. }
  409. self.processQueue.async {
  410. // debug(.service, "Start fetching HealthKitManager")
  411. guard self.settingsManager.settings.useAppleHealth else {
  412. debug(.service, "HealthKitManager cant return any data, because useAppleHealth option is disable")
  413. promise(.success([]))
  414. return
  415. }
  416. // Remove old BGs
  417. self.newGlucose = self.newGlucose
  418. .filter { $0.dateString >= Date().addingTimeInterval(-1.days.timeInterval) }
  419. // Get actual BGs (beetwen Date() - 1 day and Date())
  420. let actualGlucose = self.newGlucose
  421. .filter { $0.dateString <= Date() }
  422. // Update newGlucose
  423. self.newGlucose = self.newGlucose
  424. .filter { !actualGlucose.contains($0) }
  425. // debug(.service, "Actual glucose is \(actualGlucose)")
  426. // debug(.service, "Current state of newGlucose is \(self.newGlucose)")
  427. promise(.success(actualGlucose))
  428. }
  429. }
  430. .eraseToAnyPublisher()
  431. }
  432. func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
  433. fetch(nil)
  434. }
  435. func deleteGlucose(syncID: String) {
  436. guard settingsManager.settings.useAppleHealth,
  437. let sampleType = Config.healthBGObject,
  438. checkAvailabilitySave(objectTypeToHealthStore: sampleType)
  439. else { return }
  440. processQueue.async {
  441. let predicate = HKQuery.predicateForObjects(
  442. withMetadataKey: HKMetadataKeySyncIdentifier,
  443. operatorType: .equalTo,
  444. value: syncID
  445. )
  446. self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
  447. guard let error = error else { return }
  448. warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error)
  449. }
  450. }
  451. }
  452. // - MARK Carbs function
  453. func deleteCarbs(syncID: String, isFPU: Bool?, fpuID: String?) {
  454. guard settingsManager.settings.useAppleHealth,
  455. let sampleType = Config.healthCarbObject,
  456. checkAvailabilitySave(objectTypeToHealthStore: sampleType)
  457. else { return }
  458. if let isFPU = isFPU, !isFPU {
  459. processQueue.async {
  460. let predicate = HKQuery.predicateForObjects(
  461. withMetadataKey: HKMetadataKeySyncIdentifier,
  462. operatorType: .equalTo,
  463. value: syncID
  464. )
  465. self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
  466. guard let error = error else { return }
  467. warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error)
  468. }
  469. }
  470. } else {
  471. // need to find all syncID
  472. guard let fpuID = fpuID else { return }
  473. processQueue.async {
  474. let recentCarbs: [CarbsEntry] = self.carbsStorage.recent()
  475. let ids = recentCarbs.filter { $0.fpuID == fpuID }.compactMap(\.id)
  476. let predicate = HKQuery.predicateForObjects(
  477. withMetadataKey: HKMetadataKeySyncIdentifier,
  478. allowedValues: ids
  479. )
  480. self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
  481. guard let error = error else { return }
  482. warning(.service, "Cannot delete sample with fpuID: \(fpuID)", error: error)
  483. }
  484. }
  485. }
  486. }
  487. func carbsDidUpdate(_ carbs: [CarbsEntry]) {
  488. saveIfNeeded(carbs: carbs)
  489. }
  490. // - MARK Insulin function
  491. func deleteInsulin(syncID: String) {
  492. guard settingsManager.settings.useAppleHealth,
  493. let sampleType = Config.healthInsulinObject,
  494. checkAvailabilitySave(objectTypeToHealthStore: sampleType)
  495. else { return }
  496. processQueue.async {
  497. let predicate = HKQuery.predicateForObjects(
  498. withMetadataKey: HKMetadataKeySyncIdentifier,
  499. operatorType: .equalTo,
  500. value: syncID
  501. )
  502. self.healthKitStore.deleteObjects(of: sampleType, predicate: predicate) { _, _, error in
  503. guard let error = error else { return }
  504. warning(.service, "Cannot delete sample with syncID: \(syncID)", error: error)
  505. }
  506. }
  507. }
  508. }
  509. enum HealthKitPermissionRequestStatus {
  510. case needRequest
  511. case didRequest
  512. }
  513. enum HKError: Error {
  514. // HealthKit work only iPhone (not on iPad)
  515. case notAvailableOnCurrentDevice
  516. // Some data can be not available on current iOS-device
  517. case dataNotAvailable
  518. }
  519. private struct InsulinBolus {
  520. var id: String
  521. var amount: Decimal
  522. var date: Date
  523. }
  524. private struct InsulinBasal {
  525. var id: String
  526. var amount: Decimal
  527. var startDelivery: Date
  528. var endDelivery: Date
  529. }