AddOverrideForm.swift 22 KB

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