AdjustmentsStateModel.swift 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. import Combine
  2. import CoreData
  3. import Observation
  4. import SwiftUI
  5. extension Adjustments {
  6. @Observable final class StateModel: BaseStateModel<Provider> {
  7. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  8. @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
  9. @ObservationIgnored @Injected() var apsManager: APSManager!
  10. @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
  11. @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager!
  12. var overridePercentage: Double = 100
  13. var isEnabled = false
  14. var indefinite = true
  15. var overrideDuration: Decimal = 0
  16. var target: Decimal = 0
  17. var currentGlucoseTarget: Decimal = 100
  18. var shouldOverrideTarget: Bool = false
  19. var smbIsOff: Bool = false
  20. var id = ""
  21. var overrideName: String = ""
  22. var isPreset: Bool = false
  23. var overridePresets: [OverrideStored] = []
  24. var advancedSettings: Bool = false
  25. var isfAndCr: Bool = true
  26. var isf: Bool = true
  27. var cr: Bool = true
  28. var smbIsScheduledOff: Bool = false
  29. var start: Decimal = 0
  30. var end: Decimal = 0
  31. var smbMinutes: Decimal = 0
  32. var uamMinutes: Decimal = 0
  33. var defaultSmbMinutes: Decimal = 0
  34. var defaultUamMinutes: Decimal = 0
  35. var selectedTab: Tab = .overrides
  36. var activeOverrideName: String = ""
  37. var currentActiveOverride: OverrideStored?
  38. var activeTempTargetName: String = ""
  39. var currentActiveTempTarget: TempTargetStored?
  40. var showOverrideEditSheet = false
  41. var showTempTargetEditSheet = false
  42. var units: GlucoseUnits = .mgdL
  43. // temp target stuff
  44. let normalTarget: Decimal = 100
  45. var tempTargetDuration: Decimal = 0
  46. var tempTargetName: String = ""
  47. var tempTargetTarget: Decimal = 100
  48. var isTempTargetEnabled: Bool = false
  49. var date = Date()
  50. var newPresetName = ""
  51. var tempTargetPresets: [TempTargetStored] = []
  52. var scheduledTempTargets: [TempTargetStored] = []
  53. var percentage: Double = 100
  54. var maxValue: Decimal = 1.2
  55. var halfBasalTarget: Decimal = 160
  56. var settingHalfBasalTarget: Decimal = 160
  57. var highTTraisesSens: Bool = false
  58. var isExerciseModeActive: Bool = false
  59. var lowTTlowersSens: Bool = false
  60. var didSaveSettings: Bool = false
  61. let coredataContext = CoreDataStack.shared.newTaskContext()
  62. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  63. var isHelpSheetPresented: Bool = false
  64. var helpSheetDetent = PresentationDetent.large
  65. private var cancellables = Set<AnyCancellable>()
  66. override func subscribe() {
  67. setupNotification()
  68. setupSettings()
  69. broadcaster.register(SettingsObserver.self, observer: self)
  70. broadcaster.register(PreferencesObserver.self, observer: self)
  71. Task {
  72. await withTaskGroup(of: Void.self) { group in
  73. group.addTask {
  74. self.setupOverridePresetsArray()
  75. }
  76. group.addTask {
  77. self.setupTempTargetPresetsArray()
  78. }
  79. group.addTask {
  80. self.updateLatestOverrideConfiguration()
  81. }
  82. group.addTask {
  83. self.updateLatestTempTargetConfiguration()
  84. }
  85. }
  86. }
  87. }
  88. func getCurrentGlucoseTarget() async {
  89. let now = Date()
  90. let calendar = Calendar.current
  91. let dateFormatter = DateFormatter()
  92. dateFormatter.dateFormat = "HH:mm:ss"
  93. dateFormatter.timeZone = TimeZone.current
  94. let bgTargets = await provider.getBGTarget()
  95. let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
  96. for (index, entry) in entries.enumerated() {
  97. guard let entryTime = dateFormatter.date(from: entry.start) else {
  98. print("Invalid entry start time: \(entry.start)")
  99. continue
  100. }
  101. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  102. let entryStartTime = calendar.date(
  103. bySettingHour: entryComponents.hour!,
  104. minute: entryComponents.minute!,
  105. second: entryComponents.second!,
  106. of: now
  107. )!
  108. let entryEndTime: Date
  109. if index < entries.count - 1,
  110. let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
  111. {
  112. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  113. entryEndTime = calendar.date(
  114. bySettingHour: nextEntryComponents.hour!,
  115. minute: nextEntryComponents.minute!,
  116. second: nextEntryComponents.second!,
  117. of: now
  118. )!
  119. } else {
  120. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  121. }
  122. if now >= entryStartTime, now < entryEndTime {
  123. await MainActor.run {
  124. currentGlucoseTarget = entry.value
  125. target = currentGlucoseTarget
  126. }
  127. return
  128. }
  129. }
  130. }
  131. private func setupSettings() {
  132. units = settingsManager.settings.units
  133. defaultSmbMinutes = settingsManager.preferences.maxSMBBasalMinutes
  134. defaultUamMinutes = settingsManager.preferences.maxUAMSMBBasalMinutes
  135. maxValue = settingsManager.preferences.autosensMax
  136. settingHalfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  137. halfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  138. highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
  139. isExerciseModeActive = settingsManager.preferences.exerciseMode
  140. lowTTlowersSens = settingsManager.preferences.lowTemptargetLowersSensitivity
  141. percentage = computeAdjustedPercentage()
  142. Task {
  143. await getCurrentGlucoseTarget()
  144. }
  145. }
  146. }
  147. }
  148. // MARK: - Setup Notifications
  149. extension Adjustments.StateModel {
  150. // Custom Notification to update View when an Override has been cancelled via Home View
  151. func setupNotification() {
  152. Foundation.NotificationCenter.default.addObserver(
  153. self,
  154. selector: #selector(handleOverrideConfigurationUpdate),
  155. name: .didUpdateOverrideConfiguration,
  156. object: nil
  157. )
  158. // Custom Notification to update View when an Temp Target has been cancelled via Home View
  159. Foundation.NotificationCenter.default.addObserver(
  160. self,
  161. selector: #selector(handleTempTargetConfigurationUpdate),
  162. name: .didUpdateTempTargetConfiguration,
  163. object: nil
  164. )
  165. // Creates a publisher that updates the Override View when the Custom notification was sent (via shortcut)
  166. Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
  167. .sink { [weak self] _ in
  168. guard let self = self else { return }
  169. self.updateLatestOverrideConfiguration()
  170. }
  171. .store(in: &cancellables)
  172. // Creates a publisher that updates the Temp Target View when the Custom notification was sent (via shortcut)
  173. Foundation.NotificationCenter.default.publisher(for: .willUpdateTempTargetConfiguration)
  174. .sink { [weak self] _ in
  175. guard let self = self else { return }
  176. self.updateLatestTempTargetConfiguration()
  177. }
  178. .store(in: &cancellables)
  179. }
  180. @objc private func handleOverrideConfigurationUpdate() {
  181. updateLatestOverrideConfiguration()
  182. }
  183. @objc private func handleTempTargetConfigurationUpdate() {
  184. updateLatestTempTargetConfiguration()
  185. }
  186. // MARK: - Enact Overrides
  187. func reorderOverride(from source: IndexSet, to destination: Int) {
  188. overridePresets.move(fromOffsets: source, toOffset: destination)
  189. for (index, override) in overridePresets.enumerated() {
  190. override.orderPosition = Int16(index + 1)
  191. }
  192. do {
  193. guard viewContext.hasChanges else { return }
  194. try viewContext.save()
  195. // Update Presets View
  196. setupOverridePresetsArray()
  197. Task {
  198. await nightscoutManager.uploadProfiles()
  199. }
  200. } catch {
  201. debugPrint(
  202. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save after reordering Override Presets with error: \(error.localizedDescription)"
  203. )
  204. }
  205. }
  206. func reorderTempTargets(from source: IndexSet, to destination: Int) {
  207. tempTargetPresets.move(fromOffsets: source, toOffset: destination)
  208. for (index, tempTarget) in tempTargetPresets.enumerated() {
  209. tempTarget.orderPosition = Int16(index + 1)
  210. }
  211. do {
  212. guard viewContext.hasChanges else { return }
  213. try viewContext.save()
  214. // Update Presets View
  215. setupTempTargetPresetsArray()
  216. } catch {
  217. debugPrint(
  218. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save after reordering Temp Target Presets with error: \(error.localizedDescription)"
  219. )
  220. }
  221. }
  222. /// here we only have to update the Boolean Flag 'enabled'
  223. @MainActor func enactOverridePreset(withID id: NSManagedObjectID) async {
  224. do {
  225. /// get the underlying NSManagedObject of the Override that should be enabled
  226. let overrideToEnact = try viewContext.existingObject(with: id) as? OverrideStored
  227. overrideToEnact?.enabled = true
  228. overrideToEnact?.date = Date()
  229. overrideToEnact?.isUploadedToNS = false
  230. /// Update the 'Cancel Override' button state
  231. isEnabled = true
  232. /// disable all active Overrides and reset state variables
  233. /// do not create a OverrideRunEntry because we only want that if we cancel a running Override, not when enacting a Preset
  234. await disableAllActiveOverrides(except: id, createOverrideRunEntry: currentActiveOverride != nil)
  235. await resetStateVariables()
  236. guard viewContext.hasChanges else { return }
  237. try viewContext.save()
  238. // Update View
  239. updateLatestOverrideConfiguration()
  240. } catch {
  241. debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact Override Preset")
  242. }
  243. }
  244. // MARK: - Save the Override that we want to cancel to the OverrideRunStored Entity, then cancel ALL active overrides
  245. @MainActor func disableAllActiveOverrides(except overrideID: NSManagedObjectID? = nil, createOverrideRunEntry: Bool) async {
  246. // Get ALL NSManagedObject IDs of ALL active Override to cancel every single Override
  247. let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
  248. await viewContext.perform {
  249. do {
  250. // Fetch the existing OverrideStored objects from the context
  251. let results = try ids.compactMap { id in
  252. try self.viewContext.existingObject(with: id) as? OverrideStored
  253. }
  254. // If there are no results, return early
  255. guard !results.isEmpty else { return }
  256. // Check if we also need to create a corresponding OverrideRunStored entry, i.e. when the User uses the Cancel Button in Override View
  257. if createOverrideRunEntry {
  258. // Use the first override to create a new OverrideRunStored entry
  259. if let canceledOverride = results.first {
  260. let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
  261. newOverrideRunStored.id = UUID()
  262. newOverrideRunStored.name = canceledOverride.name
  263. newOverrideRunStored.startDate = canceledOverride.date ?? .distantPast
  264. newOverrideRunStored.endDate = Date()
  265. newOverrideRunStored
  266. .target = NSDecimalNumber(decimal: self.overrideStorage.calculateTarget(override: canceledOverride))
  267. newOverrideRunStored.override = canceledOverride
  268. newOverrideRunStored.isUploadedToNS = false
  269. }
  270. }
  271. // Disable all override except the one with overrideID
  272. for overrideToCancel in results {
  273. if overrideToCancel.objectID != overrideID {
  274. overrideToCancel.enabled = false
  275. }
  276. }
  277. // Save the context if there are changes
  278. if self.viewContext.hasChanges {
  279. try self.viewContext.save()
  280. // Update the View
  281. self.updateLatestOverrideConfiguration()
  282. }
  283. } catch {
  284. debugPrint(
  285. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
  286. )
  287. }
  288. }
  289. }
  290. // MARK: - Override (presets) save operations
  291. // Saves a Custom Override in a background context
  292. /// not a Preset
  293. func saveCustomOverride() async {
  294. let override = Override(
  295. name: overrideName,
  296. enabled: true,
  297. date: Date(),
  298. duration: overrideDuration,
  299. indefinite: indefinite,
  300. percentage: overridePercentage,
  301. smbIsOff: smbIsOff,
  302. isPreset: isPreset,
  303. id: id,
  304. overrideTarget: shouldOverrideTarget,
  305. target: target,
  306. advancedSettings: advancedSettings,
  307. isfAndCr: isfAndCr,
  308. isf: isf,
  309. cr: cr,
  310. smbIsScheduledOff: smbIsScheduledOff,
  311. start: start,
  312. end: end,
  313. smbMinutes: smbMinutes,
  314. uamMinutes: uamMinutes
  315. )
  316. // First disable all Overrides
  317. await disableAllActiveOverrides(createOverrideRunEntry: true)
  318. // Then save and activate a new custom Override
  319. await overrideStorage.storeOverride(override: override)
  320. // Reset State variables
  321. await resetStateVariables()
  322. // Update View
  323. updateLatestOverrideConfiguration()
  324. }
  325. // Save Presets
  326. /// enabled has to be false, isPreset has to be true
  327. func saveOverridePreset() async {
  328. let preset = Override(
  329. name: overrideName,
  330. enabled: false,
  331. date: Date(),
  332. duration: overrideDuration,
  333. indefinite: indefinite,
  334. percentage: overridePercentage,
  335. smbIsOff: smbIsOff,
  336. isPreset: true,
  337. id: id,
  338. overrideTarget: shouldOverrideTarget,
  339. target: target,
  340. advancedSettings: advancedSettings,
  341. isfAndCr: isfAndCr,
  342. isf: isf,
  343. cr: cr,
  344. smbIsScheduledOff: smbIsScheduledOff,
  345. start: start,
  346. end: end,
  347. smbMinutes: smbMinutes,
  348. uamMinutes: uamMinutes
  349. )
  350. async let storeOverride: () = overrideStorage.storeOverride(override: preset)
  351. async let resetState: () = resetStateVariables()
  352. _ = await (storeOverride, resetState)
  353. // Update Presets View
  354. setupOverridePresetsArray()
  355. await nightscoutManager.uploadProfiles()
  356. }
  357. // MARK: - Setup Override Presets Array
  358. // Fill the array of the Override Presets to display them in the UI
  359. private func setupOverridePresetsArray() {
  360. Task {
  361. let ids = await self.overrideStorage.fetchForOverridePresets()
  362. await updateOverridePresetsArray(with: ids)
  363. }
  364. }
  365. @MainActor private func updateOverridePresetsArray(with IDs: [NSManagedObjectID]) async {
  366. do {
  367. let overrideObjects = try IDs.compactMap { id in
  368. try viewContext.existingObject(with: id) as? OverrideStored
  369. }
  370. overridePresets = overrideObjects
  371. } catch {
  372. debugPrint(
  373. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to extract Overrides as NSManagedObjects from the NSManagedObjectIDs with error: \(error.localizedDescription)"
  374. )
  375. }
  376. }
  377. // MARK: - Override Preset Deletion
  378. func invokeOverridePresetDeletion(_ objectID: NSManagedObjectID) async {
  379. await overrideStorage.deleteOverridePreset(objectID)
  380. // Update Presets View
  381. setupOverridePresetsArray()
  382. await nightscoutManager.uploadProfiles()
  383. }
  384. // MARK: - Setup the State variables with the last Override configuration
  385. /// First get the latest Overrides corresponding NSManagedObjectID with a background fetch
  386. /// Then unpack it on the view context and update the State variables which can be used on in the View for some Logic
  387. /// This also needs to be called when we cancel an Override via the Home View to update the State of the Button for this case
  388. func updateLatestOverrideConfiguration() {
  389. Task {
  390. let id = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 1)
  391. async let updateState: () = updateLatestOverrideConfigurationOfState(from: id)
  392. async let setOverride: () = setCurrentOverride(from: id)
  393. _ = await (updateState, setOverride)
  394. }
  395. }
  396. @MainActor func updateLatestOverrideConfigurationOfState(from IDs: [NSManagedObjectID]) async {
  397. do {
  398. let result = try IDs.compactMap { id in
  399. try viewContext.existingObject(with: id) as? OverrideStored
  400. }
  401. isEnabled = result.first?.enabled ?? false
  402. if !isEnabled {
  403. await resetStateVariables()
  404. }
  405. } catch {
  406. debugPrint(
  407. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to updateLatestOverrideConfiguration"
  408. )
  409. }
  410. }
  411. // Sets the current active Preset name to show in the UI
  412. @MainActor func setCurrentOverride(from IDs: [NSManagedObjectID]) async {
  413. do {
  414. guard let firstID = IDs.first else {
  415. activeOverrideName = "Custom Override"
  416. currentActiveOverride = nil
  417. return
  418. }
  419. if let overrideToEdit = try viewContext.existingObject(with: firstID) as? OverrideStored {
  420. currentActiveOverride = overrideToEdit
  421. activeOverrideName = overrideToEdit.name ?? "Custom Override"
  422. }
  423. } catch {
  424. debugPrint(
  425. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to set active preset name with error: \(error.localizedDescription)"
  426. )
  427. }
  428. }
  429. @MainActor func duplicateOverridePresetAndCancelPreviousOverride() async {
  430. // We get the current active Preset by using currentActiveOverride which can either be a Preset or a custom Override
  431. guard let overridePresetToDuplicate = currentActiveOverride, overridePresetToDuplicate.isPreset == true else { return }
  432. // Copy the current Override-Preset to not edit the underlying Preset
  433. let duplidateId = await overrideStorage.copyRunningOverride(overridePresetToDuplicate)
  434. // Cancel the duplicated Override
  435. /// As we are on the Main Thread already we don't need to cancel via the objectID in this case
  436. do {
  437. try await viewContext.perform {
  438. overridePresetToDuplicate.enabled = false
  439. guard self.viewContext.hasChanges else { return }
  440. try self.viewContext.save()
  441. }
  442. // Update View
  443. // TODO: -
  444. if let overrideToEdit = try viewContext.existingObject(with: duplidateId) as? OverrideStored
  445. {
  446. currentActiveOverride = overrideToEdit
  447. activeOverrideName = overrideToEdit.name ?? "Custom Override"
  448. }
  449. } catch {
  450. debugPrint(
  451. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel previous override with error: \(error.localizedDescription)"
  452. )
  453. }
  454. }
  455. // MARK: - Helper functions for Overrides
  456. @MainActor func resetStateVariables() async {
  457. id = ""
  458. overrideDuration = 0
  459. indefinite = true
  460. overridePercentage = 100
  461. advancedSettings = false
  462. smbIsOff = false
  463. overrideName = ""
  464. shouldOverrideTarget = false
  465. isf = true
  466. cr = true
  467. isfAndCr = true
  468. smbIsScheduledOff = false
  469. start = 0
  470. end = 0
  471. smbMinutes = defaultSmbMinutes
  472. uamMinutes = defaultUamMinutes
  473. target = currentGlucoseTarget
  474. }
  475. static func roundTargetToStep(_ target: Decimal, _ step: Decimal) -> Decimal {
  476. // Convert target and step to NSDecimalNumber
  477. guard let targetValue = NSDecimalNumber(decimal: target).doubleValue as Double?,
  478. let stepValue = NSDecimalNumber(decimal: step).doubleValue as Double?
  479. else {
  480. return target
  481. }
  482. // Perform the remainder check using truncatingRemainder
  483. let remainder = Decimal(targetValue.truncatingRemainder(dividingBy: stepValue))
  484. if remainder != 0 {
  485. // Calculate how much to adjust (up or down) based on the remainder
  486. let adjustment = step - remainder
  487. return target + adjustment
  488. }
  489. // Return the original target if no adjustment is needed
  490. return target
  491. }
  492. static func roundOverridePercentageToStep(_ percentage: Double, _ step: Int) -> Double {
  493. let stepDouble = Double(step)
  494. // Check if overridePercentage is not divisible by the selected step
  495. if percentage.truncatingRemainder(dividingBy: stepDouble) != 0 {
  496. let roundedValue: Double
  497. if percentage > 100 {
  498. // Round down to the nearest valid step away from 100
  499. let stepCount = (percentage - 100) / stepDouble
  500. roundedValue = 100 + floor(stepCount) * stepDouble
  501. } else {
  502. // Round up to the nearest valid step away from 100
  503. let stepCount = (100 - percentage) / stepDouble
  504. roundedValue = 100 - floor(stepCount) * stepDouble
  505. }
  506. // Ensure the value stays between 10 and 200
  507. return max(10, min(roundedValue, 200))
  508. }
  509. return percentage
  510. }
  511. }
  512. // MARK: - Temp Targets
  513. extension Adjustments.StateModel {
  514. // MARK: - Setup the State variables with the last Temp Target configuration
  515. /// First get the latest Temp Target corresponding NSManagedObjectID with a background fetch
  516. /// Then unpack it on the view context and update the State variables which can be used on in the View for some Logic
  517. /// This also needs to be called when we cancel an Temp Target via the Home View to update the State of the Button for this case
  518. func updateLatestTempTargetConfiguration() {
  519. Task {
  520. let id = await tempTargetStorage.loadLatestTempTargetConfigurations(fetchLimit: 1)
  521. async let updateState: () = updateLatestTempTargetConfigurationOfState(from: id)
  522. async let setTempTarget: () = setCurrentTempTarget(from: id)
  523. _ = await (updateState, setTempTarget)
  524. }
  525. }
  526. @MainActor func updateLatestTempTargetConfigurationOfState(from IDs: [NSManagedObjectID]) async {
  527. do {
  528. let result = try IDs.compactMap { id in
  529. try viewContext.existingObject(with: id) as? TempTargetStored
  530. }
  531. isTempTargetEnabled = result.first?.enabled ?? false
  532. if !isEnabled {
  533. await resetTempTargetState()
  534. }
  535. } catch {
  536. debugPrint(
  537. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update latest temp target configuration"
  538. )
  539. }
  540. }
  541. // Sets the current active Preset name to show in the UI
  542. @MainActor func setCurrentTempTarget(from IDs: [NSManagedObjectID]) async {
  543. do {
  544. guard let firstID = IDs.first else {
  545. activeTempTargetName = "Custom Temp Target"
  546. currentActiveTempTarget = nil
  547. return
  548. }
  549. if let tempTargetToEdit = try viewContext.existingObject(with: firstID) as? TempTargetStored {
  550. currentActiveTempTarget = tempTargetToEdit
  551. activeTempTargetName = tempTargetToEdit.name ?? "Custom Temp Target"
  552. tempTargetTarget = tempTargetToEdit.target?.decimalValue ?? 0
  553. }
  554. } catch {
  555. debugPrint(
  556. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to set active preset name with error: \(error.localizedDescription)"
  557. )
  558. }
  559. }
  560. private func setupTempTargets(
  561. fetchFunction: @escaping () async -> [NSManagedObjectID],
  562. updateFunction: @escaping @MainActor([TempTargetStored]) -> Void
  563. ) {
  564. Task {
  565. let ids = await fetchFunction()
  566. let tempTargetObjects = await fetchTempTargetObjects(for: ids)
  567. await updateFunction(tempTargetObjects)
  568. }
  569. }
  570. @MainActor private func fetchTempTargetObjects(for IDs: [NSManagedObjectID]) async -> [TempTargetStored] {
  571. do {
  572. return try IDs.compactMap { id in
  573. try viewContext.existingObject(with: id) as? TempTargetStored
  574. }
  575. } catch {
  576. debugPrint(
  577. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to extract Temp Targets as NSManagedObjects from the NSManagedObjectIDs with error: \(error.localizedDescription)"
  578. )
  579. return []
  580. }
  581. }
  582. private func setupTempTargetPresetsArray() {
  583. setupTempTargets(
  584. fetchFunction: tempTargetStorage.fetchForTempTargetPresets,
  585. updateFunction: { tempTargets in
  586. self.tempTargetPresets = tempTargets
  587. }
  588. )
  589. }
  590. private func setupScheduledTempTargetsArray() {
  591. setupTempTargets(
  592. fetchFunction: tempTargetStorage.fetchScheduledTempTargets,
  593. updateFunction: { tempTargets in
  594. self.scheduledTempTargets = tempTargets
  595. }
  596. )
  597. }
  598. func saveTempTargetToStorage(tempTargets: [TempTarget]) {
  599. tempTargetStorage.saveTempTargetsToStorage(tempTargets)
  600. }
  601. func invokeSaveOfCustomTempTargets() async {
  602. if date > Date() {
  603. await saveScheduledTempTarget()
  604. } else {
  605. await saveCustomTempTarget()
  606. }
  607. }
  608. // Save scheduled Preset to Core Data
  609. func saveScheduledTempTarget() async {
  610. // Save date to a constant to allow multiple executions of this function at the same time, i.e. allowing for scheduling multiple TTs
  611. let date = self.date
  612. guard date > Date() else { return }
  613. let tempTarget = TempTarget(
  614. name: tempTargetName,
  615. createdAt: date,
  616. targetTop: tempTargetTarget,
  617. targetBottom: tempTargetTarget,
  618. duration: tempTargetDuration,
  619. enteredBy: TempTarget.manual,
  620. reason: TempTarget.custom,
  621. isPreset: false,
  622. enabled: false,
  623. halfBasalTarget: halfBasalTarget
  624. )
  625. await tempTargetStorage.storeTempTarget(tempTarget: tempTarget)
  626. // Update Scheduled Temp Targets Array
  627. setupScheduledTempTargetsArray()
  628. // If the scheduled date equals Date() enable the Preset
  629. Task {
  630. // First wait until the time has passed
  631. await waitUntilDate(date)
  632. // Then disable previous Temp Targets
  633. await disableAllActiveTempTargets(createTempTargetRunEntry: true)
  634. // Set 'enabled' property to true, i.e. enacting it in Core Data
  635. await enableScheduledTempTarget(for: date)
  636. // Activate the scheduled TT also for oref
  637. tempTargetStorage.saveTempTargetsToStorage([tempTarget])
  638. }
  639. }
  640. private func enableScheduledTempTarget(for date: Date) async {
  641. let ids = await tempTargetStorage.fetchScheduledTempTarget(for: date)
  642. guard let firstID = ids.first else {
  643. debugPrint("No Temp Target found for the specified date.")
  644. return
  645. }
  646. await setCurrentTempTarget(from: ids)
  647. await MainActor.run {
  648. do {
  649. if let tempTarget = try viewContext.existingObject(with: firstID) as? TempTargetStored {
  650. tempTarget.enabled = true
  651. try viewContext.save()
  652. // Update Buttons in Adjustments View
  653. isTempTargetEnabled = true
  654. }
  655. } catch {
  656. debugPrint("Failed to enable the Temp Target for the specified date: \(error.localizedDescription)")
  657. }
  658. }
  659. // Refresh the list of scheduled Temp Targets
  660. setupScheduledTempTargetsArray()
  661. }
  662. private func waitUntilDate(_ targetDate: Date) async {
  663. while Date() < targetDate {
  664. let timeInterval = targetDate.timeIntervalSince(Date())
  665. let sleepDuration = min(timeInterval, 60.0) // check every 60s
  666. try? await Task.sleep(nanoseconds: UInt64(sleepDuration * 1_000_000_000))
  667. }
  668. }
  669. // Creates and enacts a non Preset Temp Target
  670. func saveCustomTempTarget() async {
  671. // First disable all active TempTargets
  672. await disableAllActiveTempTargets(createTempTargetRunEntry: true)
  673. let tempTarget = TempTarget(
  674. name: tempTargetName,
  675. createdAt: date,
  676. targetTop: tempTargetTarget,
  677. targetBottom: tempTargetTarget,
  678. duration: tempTargetDuration,
  679. enteredBy: TempTarget.manual,
  680. reason: TempTarget.custom,
  681. isPreset: false,
  682. enabled: true,
  683. halfBasalTarget: halfBasalTarget
  684. )
  685. // Save Temp Target to Core Data
  686. await tempTargetStorage.storeTempTarget(tempTarget: tempTarget)
  687. // Start Temp Target for oref
  688. tempTargetStorage.saveTempTargetsToStorage([tempTarget])
  689. // Reset State variables
  690. await resetTempTargetState()
  691. // Update View
  692. isTempTargetEnabled = true
  693. updateLatestTempTargetConfiguration()
  694. }
  695. // Creates a new Temp Target Preset
  696. func saveTempTargetPreset() async {
  697. let tempTarget = TempTarget(
  698. name: tempTargetName,
  699. createdAt: Date(),
  700. targetTop: tempTargetTarget,
  701. targetBottom: tempTargetTarget,
  702. duration: tempTargetDuration,
  703. enteredBy: TempTarget.manual,
  704. reason: TempTarget.custom,
  705. isPreset: true,
  706. enabled: false,
  707. halfBasalTarget: halfBasalTarget
  708. )
  709. // Save to Core Data
  710. await tempTargetStorage.storeTempTarget(tempTarget: tempTarget)
  711. // Reset State variables
  712. await resetTempTargetState()
  713. // Update View
  714. setupTempTargetPresetsArray()
  715. }
  716. // Start Temp Target Preset
  717. /// here we only have to update the Boolean Flag 'enabled'
  718. @MainActor func enactTempTargetPreset(withID id: NSManagedObjectID) async {
  719. do {
  720. /// get the underlying NSManagedObject of the Override that should be enabled
  721. let tempTargetToEnact = try viewContext.existingObject(with: id) as? TempTargetStored
  722. tempTargetToEnact?.enabled = true
  723. tempTargetToEnact?.date = Date()
  724. tempTargetToEnact?.isUploadedToNS = false
  725. /// Update the 'Cancel Temp Target' button state
  726. isTempTargetEnabled = true
  727. /// disable all active Temp Targets and reset state variables
  728. async let disableTempTargets: () = disableAllActiveTempTargets(
  729. except: id,
  730. createTempTargetRunEntry: currentActiveTempTarget != nil
  731. )
  732. async let resetState: () = resetTempTargetState()
  733. _ = await (disableTempTargets, resetState)
  734. if viewContext.hasChanges {
  735. try viewContext.save()
  736. }
  737. // Update View
  738. updateLatestTempTargetConfiguration()
  739. // Map to TempTarget Struct
  740. let tempTarget = TempTarget(
  741. name: tempTargetToEnact?.name,
  742. createdAt: Date(),
  743. targetTop: tempTargetToEnact?.target?.decimalValue,
  744. targetBottom: tempTargetToEnact?.target?.decimalValue,
  745. duration: tempTargetToEnact?.duration?.decimalValue ?? 0,
  746. enteredBy: TempTarget.manual,
  747. reason: TempTarget.custom,
  748. isPreset: true,
  749. enabled: true,
  750. halfBasalTarget: halfBasalTarget
  751. )
  752. // Make sure the Temp Target gets used by Oref
  753. tempTargetStorage.saveTempTargetsToStorage([tempTarget])
  754. } catch {
  755. debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact Override Preset")
  756. }
  757. }
  758. // Disable all active Temp Targets
  759. @MainActor func disableAllActiveTempTargets(except id: NSManagedObjectID? = nil, createTempTargetRunEntry: Bool) async {
  760. // Get ALL NSManagedObject IDs of ALL active Temp Targets to cancel every single Temp Target
  761. let ids = await tempTargetStorage.loadLatestTempTargetConfigurations(fetchLimit: 0) // 0 = no fetch limit
  762. await viewContext.perform {
  763. do {
  764. // Fetch the existing TempTargetStored objects from the context
  765. let results = try ids.compactMap { id in
  766. try self.viewContext.existingObject(with: id) as? TempTargetStored
  767. }
  768. // If there are no results, return early
  769. guard !results.isEmpty else { return }
  770. // Check if we also need to create a corresponding TempTargetRunStored entry, i.e. when the User uses the Cancel Button in Temp Target View
  771. if createTempTargetRunEntry {
  772. // Use the first temp target to create a new TempTargetRunStored entry
  773. if let canceledTempTarget = results.first {
  774. let newTempTargetRunStored = TempTargetRunStored(context: self.viewContext)
  775. newTempTargetRunStored.id = UUID()
  776. newTempTargetRunStored.name = canceledTempTarget.name
  777. newTempTargetRunStored.startDate = canceledTempTarget.date ?? .distantPast
  778. newTempTargetRunStored.endDate = Date()
  779. newTempTargetRunStored
  780. .target = canceledTempTarget.target ?? 0
  781. newTempTargetRunStored.tempTarget = canceledTempTarget
  782. newTempTargetRunStored.isUploadedToNS = false
  783. }
  784. }
  785. // Disable all override except the one with overrideID
  786. for tempTargetToCancel in results {
  787. if tempTargetToCancel.objectID != id {
  788. tempTargetToCancel.enabled = false
  789. }
  790. }
  791. // Save the context if there are changes
  792. if self.viewContext.hasChanges {
  793. try self.viewContext.save()
  794. // Update the storage
  795. self.tempTargetStorage.saveTempTargetsToStorage([TempTarget.cancel(at: Date().addingTimeInterval(-1))])
  796. }
  797. } catch {
  798. debugPrint(
  799. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
  800. )
  801. }
  802. }
  803. }
  804. @MainActor func duplicateTempTargetPresetAndCancelPreviousTempTarget() async {
  805. // We get the current active Preset by using currentActiveTempTarget which can either be a Preset or a custom Override
  806. guard let tempTargetPresetToDuplicate = currentActiveTempTarget,
  807. tempTargetPresetToDuplicate.isPreset == true else { return }
  808. // Copy the current TempTarget-Preset to not edit the underlying Preset
  809. let duplidateId = await tempTargetStorage.copyRunningTempTarget(tempTargetPresetToDuplicate)
  810. // Cancel the duplicated Temp Target
  811. /// As we are on the Main Thread already we don't need to cancel via the objectID in this case
  812. do {
  813. try await viewContext.perform {
  814. tempTargetPresetToDuplicate.enabled = false
  815. guard self.viewContext.hasChanges else { return }
  816. try self.viewContext.save()
  817. }
  818. if let tempTargetToEdit = try viewContext.existingObject(with: duplidateId) as? TempTargetStored
  819. {
  820. currentActiveTempTarget = tempTargetToEdit
  821. activeTempTargetName = tempTargetToEdit.name ?? "Custom Temp Target"
  822. }
  823. } catch {
  824. debugPrint(
  825. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to cancel previous override with error: \(error.localizedDescription)"
  826. )
  827. }
  828. }
  829. // Deletion of Temp Targets
  830. func invokeTempTargetPresetDeletion(_ objectID: NSManagedObjectID) async {
  831. await tempTargetStorage.deleteOverridePreset(objectID)
  832. // Update Presets View
  833. setupTempTargetPresetsArray()
  834. }
  835. @MainActor func resetTempTargetState() async {
  836. tempTargetName = ""
  837. tempTargetTarget = 100
  838. tempTargetDuration = 0
  839. percentage = 100
  840. halfBasalTarget = settingHalfBasalTarget
  841. }
  842. func computeHalfBasalTarget(
  843. usingTarget initialTarget: Decimal? = nil,
  844. usingPercentage initialPercentage: Double? = nil
  845. ) -> Double {
  846. let adjustmentPercentage = initialPercentage ?? percentage
  847. let adjustmentRatio = Decimal(adjustmentPercentage / 100)
  848. let tempTargetValue: Decimal = initialTarget ?? tempTargetTarget
  849. var halfBasalTargetValue = halfBasalTarget
  850. if adjustmentRatio != 1 {
  851. halfBasalTargetValue = ((2 * adjustmentRatio * normalTarget) - normalTarget - (adjustmentRatio * tempTargetValue)) /
  852. (adjustmentRatio - 1)
  853. }
  854. return round(Double(halfBasalTargetValue))
  855. }
  856. func isAdjustSensEnabled(usingTarget initialTarget: Decimal? = nil) -> Bool {
  857. let target = initialTarget ?? tempTargetTarget
  858. if target < normalTarget, lowTTlowersSens { return true }
  859. if target > normalTarget, highTTraisesSens || isExerciseModeActive { return true }
  860. return false
  861. }
  862. func computeSliderLow(usingTarget initialTarget: Decimal? = nil) -> Double {
  863. let calcTarget = initialTarget ?? tempTargetTarget
  864. guard calcTarget != 0 else { return 15 } // oref defined maximum sensitivity
  865. let minSens = calcTarget < normalTarget ? 105 : 15
  866. return Double(max(0, minSens))
  867. }
  868. func computeSliderHigh(usingTarget initialTarget: Decimal? = nil) -> Double {
  869. let calcTarget = initialTarget ?? tempTargetTarget
  870. guard calcTarget != 0 else { return Double(maxValue * 100) } // oref defined limit for increased insulin delivery
  871. let maxSens = calcTarget > normalTarget ? 95 : Double(maxValue * 100)
  872. return maxSens
  873. }
  874. func computeAdjustedPercentage(
  875. usingHBT initialHalfBasalTarget: Decimal? = nil,
  876. usingTarget initialTarget: Decimal? = nil
  877. ) -> Double {
  878. let halfBasalTargetValue = initialHalfBasalTarget ?? halfBasalTarget
  879. let calcTarget = initialTarget ?? tempTargetTarget
  880. let deviationFromNormal = halfBasalTargetValue - normalTarget
  881. let adjustmentFactor = deviationFromNormal + (calcTarget - normalTarget)
  882. let adjustmentRatio: Decimal = (deviationFromNormal * adjustmentFactor <= 0) ? maxValue : deviationFromNormal /
  883. adjustmentFactor
  884. return Double(min(adjustmentRatio, maxValue) * 100).rounded()
  885. }
  886. }
  887. extension Adjustments.StateModel: SettingsObserver, PreferencesObserver {
  888. func settingsDidChange(_: FreeAPSSettings) {
  889. units = settingsManager.settings.units
  890. Task {
  891. await getCurrentGlucoseTarget()
  892. }
  893. }
  894. func preferencesDidChange(_: Preferences) {
  895. defaultSmbMinutes = settingsManager.preferences.maxSMBBasalMinutes
  896. defaultUamMinutes = settingsManager.preferences.maxUAMSMBBasalMinutes
  897. maxValue = settingsManager.preferences.autosensMax
  898. settingHalfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  899. halfBasalTarget = settingsManager.preferences.halfBasalExerciseTarget
  900. highTTraisesSens = settingsManager.preferences.highTemptargetRaisesSensitivity
  901. isExerciseModeActive = settingsManager.preferences.exerciseMode
  902. lowTTlowersSens = settingsManager.preferences.lowTemptargetLowersSensitivity
  903. percentage = computeAdjustedPercentage()
  904. Task {
  905. await getCurrentGlucoseTarget()
  906. }
  907. }
  908. }
  909. extension PickerSettingsProvider {
  910. func generatePickerValues(from setting: PickerSetting, units: GlucoseUnits, roundMinToStep: Bool) -> [Decimal] {
  911. if !roundMinToStep {
  912. return generatePickerValues(from: setting, units: units)
  913. }
  914. // Adjust min to be divisible by step
  915. var newSetting = setting
  916. var min = Double(newSetting.min)
  917. let step = Double(newSetting.step)
  918. let remainder = min.truncatingRemainder(dividingBy: step)
  919. if remainder != 0 {
  920. // Move min up to the next value divisible by targetStep
  921. min += (step - remainder)
  922. }
  923. newSetting.min = Decimal(min)
  924. return generatePickerValues(from: newSetting, units: units)
  925. }
  926. }
  927. enum TempTargetSensitivityAdjustmentType: String, CaseIterable {
  928. case standard = "Standard"
  929. case slider = "Custom"
  930. }
  931. enum IsfAndOrCrOptions: String, CaseIterable {
  932. case isfAndCr = "ISF/CR"
  933. case isf = "ISF"
  934. case cr = "CR"
  935. case nothing = "None"
  936. }
  937. enum DisableSmbOptions: String, CaseIterable {
  938. case dontDisable = "Don't Disable"
  939. case disable = "Disable"
  940. case disableOnSchedule = "Disable on Schedule"
  941. }
  942. func percentageDescription(_ percent: Double) -> Text? {
  943. if percent.isNaN || percent == 100 { return nil }
  944. var description: String = "Insulin doses will be "
  945. if percent < 100 {
  946. description += "decreased by "
  947. } else {
  948. description += "increased by "
  949. }
  950. let deviationFrom100 = abs(percent - 100)
  951. description += String(format: "%.0f% %.", deviationFrom100)
  952. return Text(description)
  953. }
  954. // Function to check if the phone is using 24-hour format
  955. func is24HourFormat() -> Bool {
  956. let formatter = DateFormatter()
  957. formatter.locale = Locale.current
  958. formatter.dateStyle = .none
  959. formatter.timeStyle = .short
  960. let dateString = formatter.string(from: Date())
  961. return !dateString.contains("AM") && !dateString.contains("PM")
  962. }
  963. // Helper function to convert hours to AM/PM format
  964. func convertTo12HourFormat(_ hour: Int) -> String {
  965. let formatter = DateFormatter()
  966. formatter.dateFormat = "h a"
  967. // Create a date from the hour and format it to AM/PM
  968. let calendar = Calendar.current
  969. let components = DateComponents(hour: hour)
  970. let date = calendar.date(from: components) ?? Date()
  971. return formatter.string(from: date)
  972. }
  973. // Helper function to format 24-hour numbers as two digits
  974. func format24Hour(_ hour: Int) -> String {
  975. String(format: "%02d", hour)
  976. }
  977. func formatHrMin(_ durationInMinutes: Int) -> String {
  978. let hours = durationInMinutes / 60
  979. let minutes = durationInMinutes % 60
  980. switch (hours, minutes) {
  981. case let (0, m):
  982. return "\(m) min"
  983. case let (h, 0):
  984. return "\(h) hr"
  985. default:
  986. return "\(hours) hr \(minutes) min"
  987. }
  988. }
  989. func convertToMinutes(_ hours: Int, _ minutes: Int) -> Decimal {
  990. let totalMinutes = (hours * 60) + minutes
  991. return Decimal(max(0, totalMinutes))
  992. }
  993. struct RadioButton: View {
  994. var isSelected: Bool
  995. var label: String
  996. var action: () -> Void
  997. var body: some View {
  998. Button(action: {
  999. action()
  1000. }) {
  1001. HStack {
  1002. Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
  1003. Text(label) // Add label inside the button to make it tappable
  1004. }
  1005. }
  1006. .buttonStyle(PlainButtonStyle())
  1007. }
  1008. }
  1009. struct TargetPicker: View {
  1010. let label: String
  1011. @Binding var selection: Decimal
  1012. let options: [Decimal]
  1013. let units: GlucoseUnits
  1014. var hasChanges: Binding<Bool>?
  1015. @Binding var targetStep: Decimal
  1016. @Binding var displayPickerTarget: Bool
  1017. var toggleScrollWheel: (_ picker: Bool) -> Bool
  1018. var body: some View {
  1019. HStack {
  1020. Text(label)
  1021. Spacer()
  1022. Text(
  1023. (units == .mgdL ? selection.description : selection.formattedAsMmolL) + " " + units.rawValue
  1024. )
  1025. .foregroundColor(!displayPickerTarget ? .primary : .accentColor)
  1026. }
  1027. .onTapGesture {
  1028. displayPickerTarget = toggleScrollWheel(displayPickerTarget)
  1029. }
  1030. if displayPickerTarget {
  1031. HStack {
  1032. // Radio buttons and text on the left side
  1033. VStack(alignment: .leading) {
  1034. // Radio buttons for step iteration
  1035. let stepChoices: [Decimal] = units == .mgdL ? [1, 5] : [1, 9]
  1036. ForEach(stepChoices, id: \.self) { step in
  1037. let label = (units == .mgdL ? step.description : step.formattedAsMmolL) + " " +
  1038. units.rawValue
  1039. RadioButton(
  1040. isSelected: targetStep == step,
  1041. label: label
  1042. ) {
  1043. targetStep = step
  1044. selection = Adjustments.StateModel.roundTargetToStep(selection, step)
  1045. }
  1046. .padding(.top, 10)
  1047. }
  1048. }
  1049. .frame(maxWidth: .infinity)
  1050. Spacer()
  1051. // Picker on the right side
  1052. Picker(selection: Binding(
  1053. get: { Adjustments.StateModel.roundTargetToStep(selection, targetStep) },
  1054. set: {
  1055. selection = $0
  1056. hasChanges?.wrappedValue = true // This safely updates if hasChanges is provided
  1057. }
  1058. ), label: Text("")) {
  1059. ForEach(options, id: \.self) { option in
  1060. Text((units == .mgdL ? option.description : option.formattedAsMmolL) + " " + units.rawValue)
  1061. .tag(option)
  1062. }
  1063. }
  1064. .pickerStyle(WheelPickerStyle())
  1065. .frame(maxWidth: .infinity)
  1066. }
  1067. .listRowSeparator(.hidden, edges: .top)
  1068. }
  1069. }
  1070. }