AddOverrideForm.swift 20 KB

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