AddOverrideForm.swift 26 KB

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