AddOverrideForm.swift 25 KB

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