EditOverrideForm.swift 27 KB

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