AddOverrideForm.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import Foundation
  2. import SwiftUI
  3. struct AddOverrideForm: View {
  4. @Environment(\.presentationMode) var presentationMode
  5. @Environment(\.colorScheme) var colorScheme
  6. @Environment(\.dismiss) var dismiss
  7. @Environment(AppState.self) var appState
  8. @Bindable var state: Adjustments.StateModel
  9. @State private var selectedIsfCrOption: IsfAndOrCrOptions = .isfAndCr
  10. @State private var selectedDisableSmbOption: DisableSmbOptions = .dontDisable
  11. @State private var percentageStep: Int = 5
  12. @State private var displayPickerPercentage: Bool = false
  13. @State private var displayPickerDuration: Bool = false
  14. @State private var targetStep: Decimal = 5
  15. @State private var displayPickerTarget: Bool = false
  16. @State private var displayPickerDisableSmbSchedule: Bool = false
  17. @State private var displayPickerSmbMinutes: Bool = false
  18. @State private var durationHours = 0
  19. @State private var durationMinutes = 0
  20. @State private var overrideTarget = false
  21. @State private var didPressSave = false
  22. var body: some View {
  23. NavigationView {
  24. List {
  25. addOverride()
  26. saveButton
  27. }
  28. .listSectionSpacing(10)
  29. .padding(.top, 30)
  30. .ignoresSafeArea(edges: .top)
  31. .scrollContentBackground(.hidden)
  32. .background(appState.trioBackgroundColor(for: colorScheme))
  33. .navigationTitle("Add Override")
  34. .navigationBarTitleDisplayMode(.inline)
  35. .toolbar {
  36. ToolbarItem(placement: .topBarLeading) {
  37. Button(action: {
  38. presentationMode.wrappedValue.dismiss()
  39. }, label: {
  40. Text("Cancel")
  41. })
  42. }
  43. ToolbarItem(placement: .topBarTrailing) {
  44. Button(
  45. action: {
  46. state.isHelpSheetPresented.toggle()
  47. },
  48. label: {
  49. Image(systemName: "questionmark.circle")
  50. }
  51. )
  52. }
  53. }
  54. .onAppear { targetStep = state.units == .mgdL ? 5 : 9 }
  55. .sheet(isPresented: $state.isHelpSheetPresented) {
  56. NavigationStack {
  57. List {
  58. Text(
  59. "This feature can be used to override these therepy settings for a chosen length of time:\n• Basal Rate\n ◦ Insulin Sensitivity\n ◦ Carb Ratio\n• Glucose Target"
  60. )
  61. Text(
  62. "You can also override your Max SMB Minutes and Max UAM SMB Minutes, as well as an option to disable SMBs."
  63. )
  64. Text(
  65. "Select \"Start Override\" to immediate start using the Override, or select \"Save as Preset\" to be able to easily start the Override at a later time."
  66. )
  67. Text(
  68. "If using Dynamic ISF (without Sigmoid), overriding your ISF will only adjust the limits of the ISF the algorithm is allowed to set.\n\nIf using Dynamic ISF (with Sigmoid), overriding your ISF will adjust the ISF used at your glucose target which extends to the ISF used at other glucose. Overriding your glucose target will change glucose level your ISF will be set to your profile ISF. Both of these can be combined in a single Override."
  69. )
  70. }
  71. .padding(.trailing, 10)
  72. .navigationBarTitle("Help", displayMode: .inline)
  73. Button { state.isHelpSheetPresented.toggle() }
  74. label: { Text("Got it!").frame(maxWidth: .infinity, alignment: .center) }
  75. .buttonStyle(.bordered)
  76. .padding(.top)
  77. }
  78. .padding()
  79. .presentationDetents(
  80. [.fraction(0.9), .large],
  81. selection: $state.helpSheetDetent
  82. )
  83. }
  84. }
  85. }
  86. @ViewBuilder private func addOverride() -> some View {
  87. Group {
  88. Section {
  89. HStack {
  90. Text("Name")
  91. Spacer()
  92. TextField("(Optional)", text: $state.overrideName).multilineTextAlignment(.trailing)
  93. }
  94. }
  95. .listRowBackground(Color.chart)
  96. Section(footer: state.percentageDescription(state.overridePercentage)) {
  97. // Percentage Picker
  98. HStack {
  99. Text("Change Basal Rate by")
  100. Spacer()
  101. Text("\(state.overridePercentage.formatted(.number)) %")
  102. .foregroundColor(!displayPickerPercentage ? .primary : .accentColor)
  103. }
  104. .onTapGesture {
  105. displayPickerPercentage = toggleScrollWheel(displayPickerPercentage)
  106. }
  107. if displayPickerPercentage {
  108. HStack {
  109. // Radio buttons and text on the left side
  110. VStack(alignment: .leading) {
  111. // Radio buttons for step iteration
  112. ForEach([1, 5], id: \.self) { step in
  113. RadioButton(isSelected: percentageStep == step, label: "\(step) %") {
  114. percentageStep = step
  115. state.overridePercentage = Adjustments.StateModel.roundOverridePercentageToStep(
  116. state.overridePercentage,
  117. step
  118. )
  119. }
  120. .padding(.top, 10)
  121. }
  122. }
  123. .frame(maxWidth: .infinity)
  124. Spacer()
  125. // Picker on the right side
  126. Picker(
  127. selection: Binding(
  128. get: { Int(truncating: state.overridePercentage as NSNumber) },
  129. set: { state.overridePercentage = Double($0) }
  130. ), label: Text("")
  131. ) {
  132. ForEach(Array(stride(from: 40, through: 150, by: percentageStep)), id: \.self) { percent in
  133. Text("\(percent) %").tag(percent)
  134. }
  135. }
  136. .pickerStyle(WheelPickerStyle())
  137. .frame(maxWidth: .infinity)
  138. }
  139. .frame(maxWidth: .infinity)
  140. .listRowSeparator(.hidden, edges: .top)
  141. }
  142. // Picker for ISF/CR settings
  143. Picker("Also Inversely Change", selection: $selectedIsfCrOption) {
  144. ForEach(IsfAndOrCrOptions.allCases, id: \.self) { option in
  145. Text(option.rawValue).tag(option)
  146. }
  147. }
  148. .pickerStyle(MenuPickerStyle())
  149. .onChange(of: selectedIsfCrOption) { _, newValue in
  150. switch newValue {
  151. case .isfAndCr:
  152. state.isfAndCr = true
  153. state.isf = true
  154. state.cr = true
  155. case .isf:
  156. state.isfAndCr = false
  157. state.isf = true
  158. state.cr = false
  159. case .cr:
  160. state.isfAndCr = false
  161. state.isf = false
  162. state.cr = true
  163. case .nothing:
  164. state.isfAndCr = false
  165. state.isf = false
  166. state.cr = false
  167. }
  168. }
  169. }
  170. .listRowBackground(Color.chart)
  171. Section {
  172. Toggle(isOn: $state.shouldOverrideTarget) {
  173. Text("Override Target")
  174. }
  175. if state.shouldOverrideTarget {
  176. let settingsProvider = PickerSettingsProvider.shared
  177. let glucoseSetting = PickerSetting(value: 0, step: targetStep, min: 72, max: 270, type: .glucose)
  178. TargetPicker(
  179. label: "Target Glucose",
  180. selection: Binding(
  181. get: { state.target },
  182. set: { state.target = $0 }
  183. ),
  184. options: settingsProvider.generatePickerValues(
  185. from: glucoseSetting,
  186. units: state.units,
  187. roundMinToStep: true
  188. ),
  189. units: state.units,
  190. targetStep: $targetStep,
  191. displayPickerTarget: $displayPickerTarget,
  192. toggleScrollWheel: toggleScrollWheel
  193. )
  194. .onAppear {
  195. if state.target == 0 {
  196. state.target = 100
  197. }
  198. }
  199. }
  200. }
  201. .listRowBackground(Color.chart)
  202. Section {
  203. // Picker for ISF/CR settings
  204. Picker("Disable SMBs", selection: $selectedDisableSmbOption) {
  205. ForEach(DisableSmbOptions.allCases, id: \.self) { option in
  206. Text(option.rawValue).tag(option)
  207. }
  208. }
  209. .pickerStyle(MenuPickerStyle())
  210. .onChange(of: selectedDisableSmbOption) { _, newValue in
  211. switch newValue {
  212. case .dontDisable:
  213. state.smbIsOff = false
  214. state.smbIsScheduledOff = false
  215. case .disable:
  216. state.smbIsOff = true
  217. state.smbIsScheduledOff = false
  218. case .disableOnSchedule:
  219. state.smbIsOff = false
  220. state.smbIsScheduledOff = true
  221. }
  222. }
  223. if state.smbIsScheduledOff {
  224. // First Hour SMBs Are Disabled
  225. HStack {
  226. Text("From")
  227. Spacer()
  228. Text(
  229. state.is24HourFormat() ? state.format24Hour(Int(truncating: state.start as NSNumber)) + ":00" :
  230. state.convertTo12HourFormat(Int(truncating: state.start as NSNumber))
  231. )
  232. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  233. Spacer()
  234. Divider().frame(width: 1, height: 20)
  235. Spacer()
  236. Text("To")
  237. Spacer()
  238. Text(
  239. state.is24HourFormat() ? state.format24Hour(Int(truncating: state.end as NSNumber)) + ":00" :
  240. state.convertTo12HourFormat(Int(truncating: state.end as NSNumber))
  241. )
  242. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  243. Spacer()
  244. }
  245. .onTapGesture {
  246. displayPickerDisableSmbSchedule = toggleScrollWheel(displayPickerDisableSmbSchedule)
  247. }
  248. if displayPickerDisableSmbSchedule {
  249. HStack {
  250. // From Picker
  251. Picker(selection: Binding(
  252. get: { Int(truncating: state.start as NSNumber) },
  253. set: { state.start = Decimal($0) }
  254. ), label: Text("")) {
  255. ForEach(0 ..< 24, id: \.self) { hour in
  256. Text(
  257. state.is24HourFormat() ? state.format24Hour(hour) + ":00" : state
  258. .convertTo12HourFormat(hour)
  259. )
  260. .tag(hour)
  261. }
  262. }
  263. .pickerStyle(WheelPickerStyle())
  264. .frame(maxWidth: .infinity)
  265. // To Picker
  266. Picker(selection: Binding(
  267. get: { Int(truncating: state.end as NSNumber) },
  268. set: { state.end = Decimal($0) }
  269. ), label: Text("")) {
  270. ForEach(0 ..< 24, id: \.self) { hour in
  271. Text(
  272. state.is24HourFormat() ? state.format24Hour(hour) + ":00" : state
  273. .convertTo12HourFormat(hour)
  274. )
  275. .tag(hour)
  276. }
  277. }
  278. .pickerStyle(WheelPickerStyle())
  279. .frame(maxWidth: .infinity)
  280. }
  281. .listRowSeparator(.hidden, edges: .top)
  282. }
  283. }
  284. }
  285. .listRowBackground(Color.chart)
  286. if !state.smbIsOff {
  287. Section {
  288. Toggle(isOn: $state.advancedSettings) {
  289. Text("Override Max SMB Minutes")
  290. }
  291. if state.advancedSettings {
  292. // SMB Minutes Picker
  293. HStack {
  294. Text("SMB")
  295. Spacer()
  296. Text("\(state.smbMinutes.formatted(.number)) min")
  297. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  298. Spacer()
  299. Divider().frame(width: 1, height: 20)
  300. Spacer()
  301. Text("UAM")
  302. Spacer()
  303. Text("\(state.uamMinutes.formatted(.number)) min")
  304. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  305. }
  306. .onTapGesture {
  307. displayPickerSmbMinutes = toggleScrollWheel(displayPickerSmbMinutes)
  308. }
  309. if displayPickerSmbMinutes {
  310. HStack {
  311. Picker(selection: Binding(
  312. get: { Int(truncating: state.smbMinutes as NSNumber) },
  313. set: { state.smbMinutes = Decimal($0) }
  314. ), label: Text("")) {
  315. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  316. Text("\(minute) min").tag(minute)
  317. }
  318. }
  319. .pickerStyle(WheelPickerStyle())
  320. .frame(maxWidth: .infinity)
  321. Picker(selection: Binding(
  322. get: { Int(truncating: state.uamMinutes as NSNumber) },
  323. set: { state.uamMinutes = Decimal($0) }
  324. ), label: Text("")) {
  325. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  326. Text("\(minute) min").tag(minute)
  327. }
  328. }
  329. .pickerStyle(WheelPickerStyle())
  330. .frame(maxWidth: .infinity)
  331. }
  332. .listRowSeparator(.hidden, edges: .top)
  333. }
  334. }
  335. }
  336. .listRowBackground(Color.chart)
  337. }
  338. Section {
  339. Toggle(isOn: $state.indefinite) {
  340. Text("Enable Indefinitely")
  341. }
  342. if !state.indefinite {
  343. HStack {
  344. Text("Duration")
  345. Spacer()
  346. Text(state.formatHrMin(Int(state.overrideDuration)))
  347. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  348. }
  349. .onTapGesture {
  350. displayPickerDuration = toggleScrollWheel(displayPickerDuration)
  351. }
  352. if displayPickerDuration {
  353. HStack {
  354. Picker("Hours", selection: $durationHours) {
  355. ForEach(0 ..< 24) { hour in
  356. Text("\(hour) hr").tag(hour)
  357. }
  358. }
  359. .pickerStyle(WheelPickerStyle())
  360. .frame(maxWidth: .infinity)
  361. .onChange(of: durationHours) {
  362. state.overrideDuration = state.convertToMinutes(durationHours, durationMinutes)
  363. }
  364. Picker("Minutes", selection: $durationMinutes) {
  365. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  366. Text("\(minute) min").tag(minute)
  367. }
  368. }
  369. .pickerStyle(WheelPickerStyle())
  370. .frame(maxWidth: .infinity)
  371. .onChange(of: durationMinutes) {
  372. state.overrideDuration = state.convertToMinutes(durationHours, durationMinutes)
  373. }
  374. }
  375. .listRowSeparator(.hidden, edges: .top)
  376. }
  377. }
  378. }
  379. .listRowBackground(Color.chart)
  380. }
  381. }
  382. private var saveButton: some View {
  383. let (isInvalid, errorMessage) = isOverrideInvalid()
  384. return Group {
  385. Section(
  386. header:
  387. HStack {
  388. Spacer()
  389. Text(errorMessage ?? "").textCase(nil)
  390. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  391. Spacer()
  392. },
  393. content: {
  394. Button(action: {
  395. Task {
  396. if state.indefinite { state.overrideDuration = 0 }
  397. state.isEnabled.toggle()
  398. await state.saveCustomOverride()
  399. await state.resetStateVariables()
  400. dismiss()
  401. }
  402. }, label: {
  403. Text("Start Override")
  404. })
  405. .disabled(isInvalid)
  406. .frame(maxWidth: .infinity, alignment: .center)
  407. .tint(.white)
  408. }
  409. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  410. Section {
  411. Button(action: {
  412. Task {
  413. await state.saveOverridePreset()
  414. dismiss()
  415. }
  416. }, label: {
  417. Text("Save as Preset")
  418. })
  419. .disabled(isInvalid)
  420. .frame(maxWidth: .infinity, alignment: .center)
  421. .tint(.white)
  422. }
  423. .listRowBackground(
  424. isInvalid ? Color(.systemGray4) : Color.secondary
  425. )
  426. }
  427. }
  428. private func toggleScrollWheel(_ toggle: Bool) -> Bool {
  429. displayPickerDuration = false
  430. displayPickerPercentage = false
  431. displayPickerTarget = false
  432. displayPickerDisableSmbSchedule = false
  433. displayPickerSmbMinutes = false
  434. return !toggle
  435. }
  436. private func isOverrideInvalid() -> (Bool, String?) {
  437. let noDurationSpecified = !state.indefinite && state.overrideDuration == 0
  438. let targetZeroWithOverride = state.shouldOverrideTarget && state.target == 0
  439. let allSettingsDefault = state.overridePercentage == 100 && !state.shouldOverrideTarget &&
  440. !state.advancedSettings && !state.smbIsOff && !state.smbIsScheduledOff
  441. if noDurationSpecified {
  442. return (true, "Enable indefinitely or set a duration.")
  443. }
  444. if targetZeroWithOverride {
  445. return (true, "Target glucose is out of range (\(state.units == .mgdL ? "72-270" : "4-14")).")
  446. }
  447. if allSettingsDefault {
  448. return (true, "All settings are at default values.")
  449. }
  450. return (false, nil)
  451. }
  452. }