AddOverrideForm.swift 20 KB

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