AddOverrideForm.swift 22 KB

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