AddOverrideForm.swift 22 KB

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