EditOverrideForm.swift 26 KB

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