AddOverrideForm.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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. .onTapGesture {
  104. displayPickerPercentage = toggleScrollWheel(displayPickerPercentage)
  105. }
  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. .onTapGesture {
  234. displayPickerDisableSmbSchedule = toggleScrollWheel(displayPickerDisableSmbSchedule)
  235. }
  236. Spacer()
  237. Divider().frame(width: 1, height: 20)
  238. Spacer()
  239. Text("To")
  240. Spacer()
  241. Text(
  242. state.is24HourFormat() ? state.format24Hour(Int(truncating: state.end as NSNumber)) + ":00" :
  243. state.convertTo12HourFormat(Int(truncating: state.end as NSNumber))
  244. )
  245. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  246. .onTapGesture {
  247. displayPickerDisableSmbSchedule = toggleScrollWheel(displayPickerDisableSmbSchedule)
  248. }
  249. Spacer()
  250. }
  251. if displayPickerDisableSmbSchedule {
  252. HStack {
  253. // From Picker
  254. Picker(selection: Binding(
  255. get: { Int(truncating: state.start as NSNumber) },
  256. set: { state.start = Decimal($0) }
  257. ), label: Text("")) {
  258. ForEach(0 ..< 24, id: \.self) { hour in
  259. Text(
  260. state.is24HourFormat() ? state.format24Hour(hour) + ":00" : state
  261. .convertTo12HourFormat(hour)
  262. )
  263. .tag(hour)
  264. }
  265. }
  266. .pickerStyle(WheelPickerStyle())
  267. .frame(maxWidth: .infinity)
  268. // To Picker
  269. Picker(selection: Binding(
  270. get: { Int(truncating: state.end as NSNumber) },
  271. set: { state.end = Decimal($0) }
  272. ), label: Text("")) {
  273. ForEach(0 ..< 24, id: \.self) { hour in
  274. Text(
  275. state.is24HourFormat() ? state.format24Hour(hour) + ":00" : state
  276. .convertTo12HourFormat(hour)
  277. )
  278. .tag(hour)
  279. }
  280. }
  281. .pickerStyle(WheelPickerStyle())
  282. .frame(maxWidth: .infinity)
  283. }
  284. .listRowSeparator(.hidden, edges: .top)
  285. }
  286. }
  287. }
  288. .listRowBackground(Color.chart)
  289. if !state.smbIsOff {
  290. Section {
  291. Toggle(isOn: $state.advancedSettings) {
  292. Text("Override Max SMB Minutes")
  293. }
  294. if state.advancedSettings {
  295. // SMB Minutes Picker
  296. HStack {
  297. Text("SMB")
  298. Spacer()
  299. Text("\(state.smbMinutes.formatted(.number)) min")
  300. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  301. .onTapGesture {
  302. displayPickerSmbMinutes = toggleScrollWheel(displayPickerSmbMinutes)
  303. }
  304. Spacer()
  305. Divider().frame(width: 1, height: 20)
  306. Spacer()
  307. Text("UAM")
  308. Spacer()
  309. Text("\(state.uamMinutes.formatted(.number)) min")
  310. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  311. .onTapGesture {
  312. displayPickerSmbMinutes = toggleScrollWheel(displayPickerSmbMinutes)
  313. }
  314. }
  315. if displayPickerSmbMinutes {
  316. HStack {
  317. Picker(selection: Binding(
  318. get: { Int(truncating: state.smbMinutes as NSNumber) },
  319. set: { state.smbMinutes = Decimal($0) }
  320. ), label: Text("")) {
  321. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  322. Text("\(minute) min").tag(minute)
  323. }
  324. }
  325. .pickerStyle(WheelPickerStyle())
  326. .frame(maxWidth: .infinity)
  327. Picker(selection: Binding(
  328. get: { Int(truncating: state.uamMinutes as NSNumber) },
  329. set: { state.uamMinutes = Decimal($0) }
  330. ), label: Text("")) {
  331. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  332. Text("\(minute) min").tag(minute)
  333. }
  334. }
  335. .pickerStyle(WheelPickerStyle())
  336. .frame(maxWidth: .infinity)
  337. }
  338. .listRowSeparator(.hidden, edges: .top)
  339. }
  340. }
  341. }
  342. .listRowBackground(Color.chart)
  343. }
  344. Section {
  345. Toggle(isOn: $state.indefinite) {
  346. Text("Enable Indefinitely")
  347. }
  348. if !state.indefinite {
  349. HStack {
  350. Text("Duration")
  351. Spacer()
  352. Text(state.formatHrMin(Int(state.overrideDuration)))
  353. .foregroundColor(!displayPickerDuration ? .primary : .accentColor)
  354. .onTapGesture {
  355. displayPickerDuration = toggleScrollWheel(displayPickerDuration)
  356. }
  357. }
  358. if displayPickerDuration {
  359. HStack {
  360. Picker("Hours", selection: $durationHours) {
  361. ForEach(0 ..< 24) { hour in
  362. Text("\(hour) hr").tag(hour)
  363. }
  364. }
  365. .pickerStyle(WheelPickerStyle())
  366. .frame(maxWidth: .infinity)
  367. .onChange(of: durationHours) {
  368. state.overrideDuration = state.convertToMinutes(durationHours, durationMinutes)
  369. }
  370. Picker("Minutes", selection: $durationMinutes) {
  371. ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { minute in
  372. Text("\(minute) min").tag(minute)
  373. }
  374. }
  375. .pickerStyle(WheelPickerStyle())
  376. .frame(maxWidth: .infinity)
  377. .onChange(of: durationMinutes) {
  378. state.overrideDuration = state.convertToMinutes(durationHours, durationMinutes)
  379. }
  380. }
  381. .listRowSeparator(.hidden, edges: .top)
  382. }
  383. }
  384. }
  385. .listRowBackground(Color.chart)
  386. }
  387. }
  388. private var saveButton: some View {
  389. let (isInvalid, errorMessage) = isOverrideInvalid()
  390. return Group {
  391. Section(
  392. header:
  393. HStack {
  394. Spacer()
  395. Text(errorMessage ?? "").textCase(nil)
  396. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  397. Spacer()
  398. },
  399. content: {
  400. Button(action: {
  401. Task {
  402. if state.indefinite { state.overrideDuration = 0 }
  403. state.isEnabled.toggle()
  404. await state.saveCustomOverride()
  405. await state.resetStateVariables()
  406. dismiss()
  407. }
  408. }, label: {
  409. Text("Start Override")
  410. })
  411. .disabled(isInvalid)
  412. .frame(maxWidth: .infinity, alignment: .center)
  413. .tint(.white)
  414. }
  415. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  416. Section {
  417. Button(action: {
  418. Task {
  419. await state.saveOverridePreset()
  420. dismiss()
  421. }
  422. }, label: {
  423. Text("Save as Preset")
  424. })
  425. .disabled(isInvalid)
  426. .frame(maxWidth: .infinity, alignment: .center)
  427. .tint(.white)
  428. }
  429. .listRowBackground(
  430. isInvalid ? Color(.systemGray4) : Color.secondary
  431. )
  432. }
  433. }
  434. private func toggleScrollWheel(_ toggle: Bool) -> Bool {
  435. displayPickerDuration = false
  436. displayPickerPercentage = false
  437. displayPickerTarget = false
  438. displayPickerDisableSmbSchedule = false
  439. displayPickerSmbMinutes = false
  440. return !toggle
  441. }
  442. private func isOverrideInvalid() -> (Bool, String?) {
  443. let noDurationSpecified = !state.indefinite && state.overrideDuration == 0
  444. let targetZeroWithOverride = state.shouldOverrideTarget && state.target == 0
  445. let allSettingsDefault = state.overridePercentage == 100 && !state.shouldOverrideTarget &&
  446. !state.advancedSettings && !state.smbIsOff && !state.smbIsScheduledOff
  447. if noDurationSpecified {
  448. return (true, "Enable indefinitely or set a duration.")
  449. }
  450. if targetZeroWithOverride {
  451. return (true, "Target glucose is out of range (\(state.units == .mgdL ? "72-270" : "4-14")).")
  452. }
  453. if allSettingsDefault {
  454. return (true, "All settings are at default values.")
  455. }
  456. return (false, nil)
  457. }
  458. }