AddOverrideForm.swift 22 KB

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