OnboardingStateModel+Nightscout.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import Combine
  2. import Foundation
  3. import SwiftUI
  4. // MARK: - Setup Nightscout Connection
  5. extension Onboarding.StateModel {
  6. func connectToNightscout() {
  7. if let CheckURL = nightscoutUrl.last, CheckURL == "/" {
  8. let fixedURL = nightscoutUrl.dropLast()
  9. nightscoutUrl = String(fixedURL)
  10. }
  11. guard let nightscoutUrl = URL(string: nightscoutUrl), self.nightscoutUrl.hasPrefix("https://") else {
  12. nightscoutResponseMessage = "Invalid URL"
  13. isValidNightscoutURL = false
  14. return
  15. }
  16. isConnectingToNS = true
  17. isValidNightscoutURL = true
  18. nightscoutResponseMessage = ""
  19. NightscoutAPI(url: nightscoutUrl, secret: nightscoutSecret).checkConnection()
  20. .receive(on: DispatchQueue.main)
  21. .sink { completion in
  22. switch completion {
  23. case .finished: break
  24. case let .failure(error):
  25. self.nightscoutResponseMessage = "Error: \(error.localizedDescription)"
  26. }
  27. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
  28. self.isConnectingToNS = false
  29. }
  30. } receiveValue: {
  31. self.keychain.setValue(self.nightscoutUrl, forKey: NightscoutConfig.Config.urlKey)
  32. self.keychain.setValue(self.nightscoutSecret, forKey: NightscoutConfig.Config.secretKey)
  33. self.isConnectedToNS = true
  34. }
  35. .store(in: &lifetime)
  36. }
  37. var nightscoutAPI: NightscoutAPI? {
  38. guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
  39. let url = URL(string: urlString),
  40. let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
  41. else {
  42. return nil
  43. }
  44. return NightscoutAPI(url: url, secret: secret)
  45. }
  46. func importSettingsFromNightscout(currentStep: Binding<OnboardingStep>) async {
  47. guard nightscoutAPI != nil, isConnectedToNS else {
  48. return
  49. }
  50. nightscoutImportStatus = .running
  51. do {
  52. guard let fetchedProfile = await nightscoutManager.importSettings() else {
  53. throw NSError(
  54. domain: "ImportError",
  55. code: 1,
  56. userInfo: [NSLocalizedDescriptionKey: "Cannot find the Nightscout Profile named \"default\"."]
  57. )
  58. }
  59. // determine, i.e. guesstimate, whether fetched values are mmol/L or mg/dL values
  60. let shouldConvertToMgdL = fetchedProfile.units.contains("mmol") || fetchedProfile.target_low
  61. .contains(where: { $0.value <= 39 }) || fetchedProfile.target_high.contains(where: { $0.value <= 39 })
  62. // Carb Ratios
  63. let carbratios = fetchedProfile.carbratio.map { carbratio in
  64. CarbRatioEntry(
  65. start: carbratio.time,
  66. offset: offset(carbratio.time) / 60,
  67. ratio: carbratio.value
  68. )
  69. }
  70. if carbratios.contains(where: { $0.ratio <= 0 }) {
  71. throw NSError(
  72. domain: "ImportError",
  73. code: 2,
  74. userInfo: [NSLocalizedDescriptionKey: "Invalid Carb Ratio settings in Nightscout. Import aborted."]
  75. )
  76. }
  77. let carbratiosProfile = CarbRatios(units: .grams, schedule: carbratios)
  78. // Basal Profile
  79. let basals = fetchedProfile.basal.map { basal in
  80. BasalProfileEntry(
  81. start: basal.time,
  82. minutes: offset(basal.time) / 60,
  83. rate: basal.value
  84. )
  85. }
  86. if basals.contains(where: { $0.rate <= 0 }) {
  87. throw NSError(
  88. domain: "ImportError",
  89. code: 3,
  90. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Import aborted."]
  91. )
  92. }
  93. if basals.reduce(0, { $0 + $1.rate }) <= 0 {
  94. throw NSError(
  95. domain: "ImportError",
  96. code: 4,
  97. userInfo: [
  98. NSLocalizedDescriptionKey: "Invalid Nightscout basal rates found. Basal rate total cannot be 0 U/hr. Import aborted."
  99. ]
  100. )
  101. }
  102. // Sensitivities
  103. let sensitivities = fetchedProfile.sens.map { sensitivity in
  104. InsulinSensitivityEntry(
  105. sensitivity: shouldConvertToMgdL ? correctUnitParsingOffsets(sensitivity.value.asMgdL) : sensitivity
  106. .value,
  107. offset: offset(sensitivity.time) / 60,
  108. start: sensitivity.time
  109. )
  110. }
  111. if sensitivities.contains(where: { $0.sensitivity <= 0 }) {
  112. throw NSError(
  113. domain: "ImportError",
  114. code: 5,
  115. userInfo: [NSLocalizedDescriptionKey: "Invalid Nightscout insulin sensitivity profile. Import aborted."]
  116. )
  117. }
  118. let sensitivitiesProfile = InsulinSensitivities(
  119. units: .mgdL,
  120. userPreferredUnits: .mgdL,
  121. sensitivities: sensitivities
  122. )
  123. // Targets
  124. let targets = fetchedProfile.target_low.map { target in
  125. BGTargetEntry(
  126. low: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
  127. high: shouldConvertToMgdL ? correctUnitParsingOffsets(target.value.asMgdL) : target.value,
  128. start: target.time,
  129. offset: offset(target.time) / 60
  130. )
  131. }
  132. let targetsProfile = BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: targets)
  133. // Store therapy settings in-memory in state model for further review
  134. finalizeImport(
  135. targets: targetsProfile,
  136. basals: basals,
  137. carbRatios: carbratiosProfile,
  138. sensitivities: sensitivitiesProfile,
  139. userPreferredUnitsFromImport: fetchedProfile.units,
  140. currentStep: currentStep
  141. )
  142. } catch {
  143. await MainActor.run {
  144. self.nightscoutImportError = NightscoutImportError(message: error.localizedDescription)
  145. self.nightscoutImportStatus = .failed
  146. debug(.service, "Settings import failed with error: \(error.localizedDescription)")
  147. }
  148. }
  149. }
  150. fileprivate func finalizeImport(
  151. targets targetsProfile: BGTargets,
  152. basals: [BasalProfileEntry],
  153. carbRatios carbratiosProfile: CarbRatios,
  154. sensitivities sensitivitiesProfile: InsulinSensitivities,
  155. userPreferredUnitsFromImport: String,
  156. currentStep: Binding<OnboardingStep>
  157. ) {
  158. /// First, very important: assign `units` so that `xxxRateValues` contain the proper values
  159. /// and array has the correct number of elements.
  160. /// If not done here, this may lead to index-out-of-bound errors for users importing mmol/L settings.
  161. units = userPreferredUnitsFromImport.contains("mmol") ? .mmolL : .mgdL
  162. // Parse: targetsProfile → targetItems
  163. targetItems = targetsProfile.targets.map { entry in
  164. let timeIndex = targetTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
  165. let lowIndex = targetRateValues.enumerated().min(by: {
  166. abs($0.element - entry.low) < abs($1.element - entry.low)
  167. })?.offset ?? 0
  168. return TargetsEditor.Item(lowIndex: lowIndex, highIndex: lowIndex, timeIndex: timeIndex)
  169. }
  170. initialTargetItems = targetItems
  171. // Parse: basals → basalProfileItems
  172. basalProfileItems = basals.map { entry in
  173. let timeIndex = basalProfileTimeValues.firstIndex(where: { Int($0) == entry.minutes * 60 }) ?? 0
  174. let rateIndex = basalProfileRateValues.enumerated().min(by: {
  175. abs($0.element - entry.rate) < abs($1.element - entry.rate)
  176. })?.offset ?? 0
  177. return BasalProfileEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  178. }
  179. initialBasalProfileItems = basalProfileItems
  180. // Parse: carbratiosProfile → carbRatioItems
  181. carbRatioItems = carbratiosProfile.schedule.map { entry in
  182. let timeIndex = carbRatioTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
  183. let rateIndex = carbRatioRateValues.enumerated().min(by: {
  184. abs($0.element - entry.ratio) < abs($1.element - entry.ratio)
  185. })?.offset ?? 0
  186. return CarbRatioEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  187. }
  188. initialCarbRatioItems = carbRatioItems
  189. // Parse: sensitivitiesProfile → isfItems
  190. isfItems = sensitivitiesProfile.sensitivities.map { entry in
  191. let timeIndex = isfTimeValues.firstIndex(where: { Int($0) == entry.offset * 60 }) ?? 0
  192. let rateIndex = isfRateValues.enumerated().min(by: {
  193. abs($0.element - entry.sensitivity) < abs($1.element - entry.sensitivity)
  194. })?.offset ?? 0
  195. return ISFEditor.Item(rateIndex: rateIndex, timeIndex: timeIndex)
  196. }
  197. initialISFItems = isfItems
  198. DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
  199. self.nightscoutImportStatus = .finished
  200. // navigate to the next onboarding step
  201. if let next = currentStep.wrappedValue.next {
  202. currentStep.wrappedValue = next
  203. }
  204. }
  205. }
  206. fileprivate func correctUnitParsingOffsets(_ parsedValue: Decimal) -> Decimal {
  207. Int(parsedValue) % 2 == 0 ? parsedValue : parsedValue + 1
  208. }
  209. fileprivate func offset(_ string: String) -> Int {
  210. let hours = Int(string.prefix(2)) ?? 0
  211. let minutes = Int(string.suffix(2)) ?? 0
  212. return ((hours * 60) + minutes) * 60
  213. }
  214. enum ImportStatus {
  215. case none
  216. case running
  217. case finished
  218. case failed
  219. }
  220. }
  221. struct NightscoutImportError: Identifiable {
  222. let id = UUID()
  223. let message: String
  224. }