EditOverrideForm.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import Foundation
  2. import SwiftUI
  3. struct EditOverrideForm: View {
  4. @ObservedObject var override: OverrideStored
  5. @Environment(\.presentationMode) var presentationMode
  6. @Environment(\.colorScheme) var colorScheme
  7. @StateObject var state: OverrideConfig.StateModel
  8. @State private var name: String
  9. @State private var percentage: Double
  10. @State private var indefinite: Bool
  11. @State private var duration: Decimal
  12. @State private var target: Decimal?
  13. @State private var advancedSettings: Bool
  14. @State private var smbIsOff: Bool
  15. @State private var smbIsScheduledOff: Bool
  16. @State private var start: Decimal?
  17. @State private var end: Decimal?
  18. @State private var isfAndCr: Bool
  19. @State private var isf: Bool
  20. @State private var cr: Bool
  21. @State private var smbMinutes: Decimal?
  22. @State private var uamMinutes: Decimal?
  23. @State private var selectedIsfCrOption: isfAndOrCrOptions
  24. @State private var selectedDisableSmbOption: disableSmbOptions
  25. @State private var hasChanges = false
  26. @State private var isEditing = false
  27. @State private var target_override = false
  28. @State private var displayPickerPercentage: Bool = false
  29. @State private var displayPickerDuration: Bool = false
  30. @State private var displayPickerTarget: Bool = false
  31. @State private var displayPickerDisableSmbSchedule: Bool = false
  32. @State private var displayPickerSmbMinutes: Bool = false
  33. init(overrideToEdit: OverrideStored, state: OverrideConfig.StateModel) {
  34. override = overrideToEdit
  35. _state = StateObject(wrappedValue: state)
  36. _name = State(initialValue: overrideToEdit.name ?? "")
  37. _percentage = State(initialValue: overrideToEdit.percentage)
  38. _indefinite = State(initialValue: overrideToEdit.indefinite)
  39. _duration = State(initialValue: overrideToEdit.duration?.decimalValue ?? 0)
  40. _target = State(initialValue: overrideToEdit.target?.decimalValue)
  41. _target_override = State(initialValue: overrideToEdit.target?.decimalValue != 0)
  42. _advancedSettings = State(initialValue: overrideToEdit.advancedSettings)
  43. _smbIsOff = State(initialValue: overrideToEdit.smbIsOff)
  44. _smbIsScheduledOff = State(initialValue: overrideToEdit.smbIsScheduledOff)
  45. _start = State(initialValue: overrideToEdit.start?.decimalValue)
  46. _end = State(initialValue: overrideToEdit.end?.decimalValue)
  47. _isfAndCr = State(initialValue: overrideToEdit.isfAndCr)
  48. _isf = State(initialValue: overrideToEdit.isf)
  49. _cr = State(initialValue: overrideToEdit.cr)
  50. _selectedIsfCrOption = State(
  51. initialValue: overrideToEdit.isfAndCr ? .isfAndCr
  52. : (overrideToEdit.isf ? .isf : (overrideToEdit.cr ? .cr : .none))
  53. )
  54. _selectedDisableSmbOption = State(
  55. initialValue: overrideToEdit.smbIsScheduledOff ? .disableOnSchedule
  56. : (overrideToEdit.smbIsOff ? .disable : .dontDisable)
  57. )
  58. _smbMinutes = State(initialValue: overrideToEdit.smbMinutes?.decimalValue)
  59. _uamMinutes = State(initialValue: overrideToEdit.uamMinutes?.decimalValue)
  60. }
  61. enum isfAndOrCrOptions: String, CaseIterable {
  62. case isfAndCr = "ISF/CR"
  63. case isf = "ISF"
  64. case cr = "CR"
  65. case none = "None"
  66. }
  67. enum disableSmbOptions: String, CaseIterable {
  68. case dontDisable = "Don't Disable"
  69. case disable = "Disable"
  70. case disableOnSchedule = "Disable on Schedule"
  71. }
  72. var color: LinearGradient {
  73. colorScheme == .dark ? LinearGradient(
  74. gradient: Gradient(colors: [
  75. Color.bgDarkBlue,
  76. Color.bgDarkerDarkBlue
  77. ]),
  78. startPoint: .top,
  79. endPoint: .bottom
  80. ) :
  81. LinearGradient(
  82. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  83. startPoint: .top,
  84. endPoint: .bottom
  85. )
  86. }
  87. private var formatter: NumberFormatter {
  88. let formatter = NumberFormatter()
  89. formatter.numberStyle = .decimal
  90. formatter.maximumFractionDigits = 0
  91. return formatter
  92. }
  93. private var glucoseFormatter: NumberFormatter {
  94. let formatter = NumberFormatter()
  95. formatter.numberStyle = .decimal
  96. formatter.maximumFractionDigits = 0
  97. if state.units == .mmolL {
  98. formatter.maximumFractionDigits = 1
  99. }
  100. formatter.roundingMode = .halfUp
  101. return formatter
  102. }
  103. var body: some View {
  104. NavigationView {
  105. List {
  106. editOverride()
  107. saveButton
  108. }
  109. .listSectionSpacing(20)
  110. .listRowSpacing(10)
  111. .scrollContentBackground(.hidden).background(color)
  112. .navigationTitle("Edit Override")
  113. .navigationBarTitleDisplayMode(.inline)
  114. .navigationBarItems(trailing: Button("Cancel") {
  115. presentationMode.wrappedValue.dismiss()
  116. })
  117. .onDisappear {
  118. if !hasChanges {
  119. // Reset UI changes
  120. resetValues()
  121. }
  122. }
  123. }
  124. }
  125. @ViewBuilder private func editOverride() -> some View {
  126. Section {
  127. let pad: CGFloat = 3
  128. if override.name != nil {
  129. VStack {
  130. HStack {
  131. Text("Name")
  132. Spacer()
  133. TextField("Name", text: $name)
  134. .onChange(of: name) { hasChanges = true }
  135. .multilineTextAlignment(.trailing)
  136. }
  137. .padding(.vertical, pad)
  138. }
  139. }
  140. VStack {
  141. Toggle(isOn: $indefinite) { Text("Enable Indefinitely") }
  142. .padding(.vertical, pad)
  143. .onChange(of: indefinite) { hasChanges = true }
  144. if !indefinite {
  145. HStack {
  146. Text("Duration")
  147. Spacer()
  148. Text(formatHrMin(Int(truncating: duration as NSNumber)))
  149. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  150. }
  151. .padding(.vertical, pad)
  152. .onTapGesture {
  153. displayPickerDuration.toggle()
  154. }
  155. if displayPickerDuration {
  156. HStack {
  157. Picker(
  158. selection: Binding(
  159. get: {
  160. Int(truncating: duration as NSNumber) / 60
  161. },
  162. set: {
  163. duration = Decimal($0 * 60 + Int(truncating: duration as NSNumber) % 60)
  164. hasChanges = true
  165. }
  166. ),
  167. label: Text("")
  168. ) {
  169. ForEach(0 ..< 24) { hour in
  170. Text("\(hour) hr").tag(hour)
  171. }
  172. }
  173. .pickerStyle(WheelPickerStyle())
  174. .frame(maxWidth: .infinity)
  175. Picker(
  176. selection: Binding(
  177. get: {
  178. Int(truncating: duration as NSNumber) %
  179. 60 // Convert Decimal to Int for modulus operation
  180. },
  181. set: {
  182. duration = Decimal((Int(truncating: duration as NSNumber) / 60) * 60 + $0)
  183. hasChanges = true
  184. }
  185. ),
  186. label: Text("")
  187. ) {
  188. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  189. Text("\(minute) min").tag(minute)
  190. }
  191. }
  192. .pickerStyle(WheelPickerStyle())
  193. .frame(maxWidth: .infinity)
  194. }
  195. }
  196. }
  197. }
  198. // Percentage Picker
  199. VStack {
  200. HStack {
  201. Text("Change Basal Rate by")
  202. Spacer()
  203. Text("\(percentage.formatted(.number)) %")
  204. .foregroundColor(!displayPickerPercentage ? .primary : .accentColor)
  205. }
  206. .padding(.vertical, pad)
  207. .onTapGesture {
  208. displayPickerPercentage.toggle()
  209. }
  210. if displayPickerPercentage {
  211. Picker(
  212. selection: Binding(
  213. get: { max(10, min(floor(percentage / 5) * 5, 200)) },
  214. // round down to nearest multiple of 5 and limit from 10-200
  215. set: {
  216. percentage = $0
  217. hasChanges = true
  218. }
  219. ),
  220. label: Text("")
  221. ) {
  222. ForEach(Array(stride(from: 10.0, through: 200.0, by: 5.0)), id: \.self) { percent in
  223. Text("\(Int(percent)) %").tag(percent)
  224. }
  225. }
  226. .pickerStyle(WheelPickerStyle())
  227. .frame(maxWidth: .infinity)
  228. }
  229. // Picker for ISF/CR settings
  230. Picker("Also Change", selection: $selectedIsfCrOption) {
  231. ForEach(isfAndOrCrOptions.allCases, id: \.self) { option in
  232. Text(option.rawValue).tag(option)
  233. }
  234. }
  235. .padding(.top, pad)
  236. .pickerStyle(MenuPickerStyle())
  237. .onChange(of: selectedIsfCrOption) { _, newValue in
  238. switch newValue {
  239. case .isfAndCr:
  240. isfAndCr = true
  241. isf = false
  242. cr = false
  243. case .isf:
  244. isfAndCr = false
  245. isf = true
  246. cr = false
  247. case .cr:
  248. isfAndCr = false
  249. isf = false
  250. cr = true
  251. case .none:
  252. isfAndCr = false
  253. isf = false
  254. cr = false
  255. }
  256. hasChanges = true
  257. }
  258. }
  259. VStack {
  260. Toggle(isOn: $target_override) {
  261. Text("Override Target")
  262. }
  263. .padding(.vertical, pad)
  264. .onChange(of: target_override) {
  265. hasChanges = true
  266. }
  267. // Target Glucose Picker
  268. if target_override {
  269. let step: Decimal = state.units == .mgdL ? 1 : 2
  270. ScrollWheelPicker(
  271. label: "Target Glucose",
  272. selection: Binding(
  273. get: { target ?? Decimal(100) },
  274. set: { target = $0 }
  275. ),
  276. options: Array(stride(from: Decimal(72), through: Decimal(270), by: step)),
  277. formatter: { formattedGlucose(glucose: $0) },
  278. hasChanges: $hasChanges
  279. )
  280. }
  281. }
  282. VStack {
  283. // Picker for Disable SMB settings
  284. Picker("Disable SMBs", selection: $selectedDisableSmbOption) {
  285. ForEach(disableSmbOptions.allCases, id: \.self) { option in
  286. Text(option.rawValue).tag(option)
  287. }
  288. }
  289. .padding(.vertical, pad)
  290. .pickerStyle(MenuPickerStyle())
  291. .onChange(of: selectedDisableSmbOption) { _, newValue in
  292. switch newValue {
  293. case .dontDisable:
  294. smbIsOff = false
  295. smbIsScheduledOff = false
  296. case .disable:
  297. smbIsOff = true
  298. smbIsScheduledOff = false
  299. case .disableOnSchedule:
  300. smbIsOff = false
  301. smbIsScheduledOff = true
  302. }
  303. hasChanges = true
  304. }
  305. if smbIsScheduledOff {
  306. // First Hour SMBs Are Disabled
  307. HStack {
  308. Text("From")
  309. Spacer()
  310. Text(
  311. is24HourFormat() ? format24Hour(Int(truncating: start! as NSNumber)) + ":00" :
  312. convertTo12HourFormat(Int(truncating: start! as NSNumber))
  313. )
  314. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  315. Divider().frame(width: 1, height: 20)
  316. Text("To")
  317. Spacer()
  318. Text(
  319. is24HourFormat() ? format24Hour(Int(truncating: end! as NSNumber)) + ":00" :
  320. convertTo12HourFormat(Int(truncating: end! as NSNumber))
  321. )
  322. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  323. }
  324. .padding(.vertical, pad)
  325. .onTapGesture {
  326. displayPickerDisableSmbSchedule.toggle()
  327. }
  328. if displayPickerDisableSmbSchedule {
  329. HStack {
  330. Picker(selection: Binding(
  331. get: { Int(truncating: start! as NSNumber) },
  332. set: {
  333. start = Decimal($0)
  334. hasChanges = true
  335. }
  336. ), label: Text("")) {
  337. if is24HourFormat() {
  338. ForEach(0 ..< 24, id: \.self) { hour in
  339. Text(format24Hour(hour) + ":00").tag(hour)
  340. }
  341. } else {
  342. ForEach(0 ..< 24, id: \.self) { hour in
  343. Text(convertTo12HourFormat(hour)).tag(hour)
  344. }
  345. }
  346. }
  347. .pickerStyle(WheelPickerStyle())
  348. .frame(maxWidth: .infinity)
  349. Picker(selection: Binding(
  350. get: { Int(truncating: end! as NSNumber) },
  351. set: {
  352. end = Decimal($0)
  353. hasChanges = true
  354. }
  355. ), label: Text("")) {
  356. if is24HourFormat() {
  357. ForEach(0 ..< 24, id: \.self) { hour in
  358. Text(format24Hour(hour) + ":00").tag(hour)
  359. }
  360. } else {
  361. ForEach(0 ..< 24, id: \.self) { hour in
  362. Text(convertTo12HourFormat(hour)).tag(hour)
  363. }
  364. }
  365. }
  366. .pickerStyle(WheelPickerStyle())
  367. .frame(maxWidth: .infinity)
  368. }
  369. }
  370. }
  371. }
  372. if !smbIsOff {
  373. VStack {
  374. Toggle(isOn: $advancedSettings) {
  375. Text("Change Max SMB Minutes")
  376. }
  377. .padding(.vertical, pad)
  378. .onChange(of: advancedSettings) { hasChanges = true }
  379. if advancedSettings {
  380. // SMB Minutes Picker
  381. VStack {
  382. HStack {
  383. Text("SMB")
  384. Spacer()
  385. Text("\(smbMinutes?.formatted(.number) ?? "\(state.defaultSmbMinutes)") min")
  386. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  387. Divider().frame(width: 1, height: 20)
  388. Text("UAM")
  389. Spacer()
  390. Text("\(uamMinutes?.formatted(.number) ?? "\(state.defaultUamMinutes)") min")
  391. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  392. }
  393. .padding(.vertical, pad)
  394. .onTapGesture {
  395. displayPickerSmbMinutes.toggle()
  396. }
  397. if displayPickerSmbMinutes {
  398. HStack {
  399. Picker(
  400. selection: Binding(
  401. get: { smbMinutes ?? state.defaultSmbMinutes },
  402. set: {
  403. smbMinutes = $0
  404. hasChanges = true
  405. }
  406. ),
  407. label: Text("")
  408. ) {
  409. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  410. Text("\(minute) min").tag(Decimal(minute))
  411. }
  412. }
  413. .pickerStyle(WheelPickerStyle())
  414. .frame(maxWidth: .infinity)
  415. Picker(
  416. selection: Binding(
  417. get: { uamMinutes ?? state.defaultUamMinutes },
  418. set: {
  419. uamMinutes = $0
  420. hasChanges = true
  421. }
  422. ),
  423. label: Text("")
  424. ) {
  425. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  426. Text("\(minute) min").tag(Decimal(minute))
  427. }
  428. }
  429. .pickerStyle(WheelPickerStyle())
  430. .frame(maxWidth: .infinity)
  431. }
  432. }
  433. }
  434. }
  435. }
  436. }
  437. }
  438. .listRowBackground(Color.chart)
  439. }
  440. private var saveButton: some View {
  441. let (isInvalid, errorMessage) = isOverrideInvalid()
  442. return Section(
  443. footer: Text(errorMessage ?? "")
  444. .foregroundColor(.red)
  445. ) {
  446. Button(action: {
  447. saveChanges()
  448. do {
  449. guard let moc = override.managedObjectContext else { return }
  450. guard moc.hasChanges else { return }
  451. try moc.save()
  452. if let currentActiveOverride = state.currentActiveOverride {
  453. Task {
  454. await state.disableAllActiveOverrides(
  455. except: currentActiveOverride.objectID,
  456. createOverrideRunEntry: false
  457. )
  458. // Update View
  459. state.updateLatestOverrideConfiguration()
  460. }
  461. }
  462. hasChanges = false
  463. presentationMode.wrappedValue.dismiss()
  464. } catch {
  465. debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to edit Override")
  466. }
  467. }, label: {
  468. Text("Save Override")
  469. })
  470. .disabled(isInvalid) // Disable button if changes are invalid
  471. .frame(maxWidth: .infinity, alignment: .center)
  472. .tint(.white)
  473. }
  474. .listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  475. }
  476. private func isOverrideInvalid() -> (Bool, String?) {
  477. let noDurationSpecified = !indefinite && duration == 0
  478. let targetZeroWithOverride = target_override && (target ?? 0 < 72 || target ?? 0 > 270)
  479. let allSettingsDefault = percentage == 100 && !target_override && !advancedSettings &&
  480. !smbIsOff && !smbIsScheduledOff
  481. if noDurationSpecified {
  482. return (true, "Enable indefinitely or set a duration.")
  483. }
  484. if targetZeroWithOverride {
  485. return (true, "Target glucose is out of range (\(state.units == .mgdL ? "72-270" : "4-14")).")
  486. }
  487. if allSettingsDefault {
  488. return (true, "All settings are at default values.")
  489. }
  490. if !hasChanges {
  491. return (true, nil)
  492. }
  493. return (false, nil)
  494. }
  495. private func formattedGlucose(glucose: Decimal) -> String {
  496. let formattedValue: String
  497. if state.units == .mgdL {
  498. formattedValue = glucoseFormatter.string(from: glucose as NSDecimalNumber) ?? "\(glucose)"
  499. } else {
  500. formattedValue = glucose.formattedAsMmolL
  501. }
  502. return "\(formattedValue) \(state.units.rawValue)"
  503. }
  504. private func saveChanges() {
  505. if !override.isPreset, hasChanges, name == (override.name ?? "") {
  506. override.name = "Custom Override"
  507. } else {
  508. override.name = name
  509. }
  510. override.percentage = percentage
  511. override.indefinite = indefinite
  512. override.duration = NSDecimalNumber(decimal: duration)
  513. if target_override {
  514. override.target = target.map {
  515. state.units == .mmolL ? NSDecimalNumber(decimal: $0.asMgdL) : NSDecimalNumber(decimal: $0)
  516. }
  517. } else {
  518. override.target = 0
  519. }
  520. override.advancedSettings = advancedSettings
  521. override.smbIsOff = smbIsOff
  522. override.smbIsScheduledOff = smbIsScheduledOff
  523. override.start = start.map { NSDecimalNumber(decimal: $0) }
  524. override.end = end.map { NSDecimalNumber(decimal: $0) }
  525. override.isfAndCr = isfAndCr
  526. override.isf = isf
  527. override.cr = cr
  528. override.smbMinutes = smbMinutes.map { NSDecimalNumber(decimal: $0) }
  529. override.uamMinutes = uamMinutes.map { NSDecimalNumber(decimal: $0) }
  530. override.isUploadedToNS = false
  531. }
  532. private func resetValues() {
  533. name = override.name ?? ""
  534. percentage = override.percentage
  535. indefinite = override.indefinite
  536. duration = override.duration?.decimalValue ?? 0
  537. target = override.target?.decimalValue
  538. advancedSettings = override.advancedSettings
  539. smbIsOff = override.smbIsOff
  540. smbIsScheduledOff = override.smbIsScheduledOff
  541. start = override.start?.decimalValue
  542. end = override.end?.decimalValue
  543. isfAndCr = override.isfAndCr
  544. isf = override.isf
  545. cr = override.cr
  546. smbMinutes = override.smbMinutes?.decimalValue ?? state.defaultSmbMinutes
  547. uamMinutes = override.uamMinutes?.decimalValue ?? state.defaultUamMinutes
  548. }
  549. }
  550. struct ScrollWheelPicker<T: Hashable>: View {
  551. let label: String
  552. @Binding var selection: T
  553. let options: [T]
  554. let formatter: (T) -> String
  555. @Binding var hasChanges: Bool
  556. @State private var isDisplayed: Bool = false
  557. var body: some View {
  558. VStack {
  559. HStack {
  560. Text(label)
  561. Spacer()
  562. Text(formatter(selection))
  563. .foregroundColor(.accentColor)
  564. }
  565. .onTapGesture {
  566. isDisplayed.toggle()
  567. }
  568. if isDisplayed {
  569. Picker(selection: Binding(
  570. get: { selection },
  571. set: {
  572. selection = $0
  573. hasChanges = true
  574. }
  575. ), label: Text("")) {
  576. ForEach(options, id: \.self) { option in
  577. Text(formatter(option)).tag(option)
  578. }
  579. }
  580. .pickerStyle(WheelPickerStyle())
  581. .frame(maxWidth: .infinity)
  582. }
  583. }
  584. }
  585. }