AddOverrideForm.swift 28 KB

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