AddOverrideForm.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  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. state.overridePercentage = OverrideConfig.StateModel.roundOverridePercentageToStep(
  188. state.overridePercentage,
  189. step
  190. )
  191. }
  192. .padding(.top, 10)
  193. }
  194. }
  195. .frame(maxWidth: .infinity)
  196. Spacer()
  197. // Picker on the right side
  198. Picker(
  199. selection: Binding(
  200. get: { Int(truncating: state.overridePercentage as NSNumber) },
  201. set: { state.overridePercentage = Double($0) }
  202. ), label: Text("")
  203. ) {
  204. ForEach(Array(stride(from: 40, through: 150, by: percentageStep)), id: \.self) { percent in
  205. Text("\(percent) %").tag(percent)
  206. }
  207. }
  208. .pickerStyle(WheelPickerStyle())
  209. .frame(maxWidth: .infinity)
  210. }
  211. .frame(maxWidth: .infinity)
  212. }
  213. // Picker for ISF/CR settings
  214. Picker("Also Inversely Change", selection: $selectedIsfCrOption) {
  215. ForEach(IsfAndOrCrOptions.allCases, id: \.self) { option in
  216. Text(option.rawValue).tag(option)
  217. }
  218. }
  219. .padding(.top, pad)
  220. .pickerStyle(MenuPickerStyle())
  221. .onChange(of: selectedIsfCrOption) { _, newValue in
  222. switch newValue {
  223. case .isfAndCr:
  224. state.isfAndCr = true
  225. state.isf = true
  226. state.cr = true
  227. case .isf:
  228. state.isfAndCr = false
  229. state.isf = true
  230. state.cr = false
  231. case .cr:
  232. state.isfAndCr = false
  233. state.isf = false
  234. state.cr = true
  235. case .nothing:
  236. state.isfAndCr = false
  237. state.isf = false
  238. state.cr = false
  239. }
  240. }
  241. }
  242. VStack {
  243. Toggle(isOn: $state.shouldOverrideTarget) {
  244. Text("Override Profile Target")
  245. }
  246. .padding(.vertical, pad)
  247. if state.shouldOverrideTarget {
  248. VStack {
  249. HStack {
  250. Text("Target Glucose")
  251. Spacer()
  252. Text(
  253. (state.units == .mgdL ? state.target.description : state.target.formattedAsMmolL) + " " + state
  254. .units.rawValue
  255. )
  256. .foregroundColor(!displayPickerTarget ? .primary : .accentColor)
  257. }
  258. .padding(.vertical, pad)
  259. .onTapGesture {
  260. displayPickerTarget.toggle()
  261. }
  262. if displayPickerTarget {
  263. HStack {
  264. // Radio buttons and text on the left side
  265. VStack(alignment: .leading) {
  266. // Radio buttons for step iteration
  267. let stepChoices: [Decimal] = state.units == .mgdL ? [1, 5] : [1, 9]
  268. ForEach(stepChoices, id: \.self) { step in
  269. let label = (state.units == .mgdL ? step.description : step.formattedAsMmolL) + " " +
  270. state.units.rawValue
  271. RadioButton(
  272. isSelected: targetStep == step,
  273. label: label
  274. ) {
  275. targetStep = step
  276. state.target = OverrideConfig.StateModel.roundTargetToStep(state.target, targetStep)
  277. }
  278. .padding(.top, 10)
  279. }
  280. }
  281. .frame(maxWidth: .infinity)
  282. Spacer()
  283. // Picker on the right side
  284. Picker(selection: Binding(
  285. get: { OverrideConfig.StateModel.roundTargetToStep(state.target, targetStep) },
  286. set: { state.target = $0 }
  287. ), label: Text("")) {
  288. ForEach(
  289. generateTargetPickerValues(),
  290. id: \.self
  291. ) { glucose in
  292. Text(
  293. (state.units == .mgdL ? glucose.description : glucose.formattedAsMmolL) + " " + state
  294. .units.rawValue
  295. )
  296. .tag(glucose)
  297. }
  298. }
  299. .pickerStyle(WheelPickerStyle())
  300. .frame(maxWidth: .infinity)
  301. }
  302. }
  303. }
  304. }
  305. }
  306. VStack {
  307. // Picker for ISF/CR settings
  308. Picker("Disable SMBs", selection: $selectedDisableSmbOption) {
  309. ForEach(DisableSmbOptions.allCases, id: \.self) { option in
  310. Text(option.rawValue).tag(option)
  311. }
  312. }
  313. .padding(.vertical, pad)
  314. .pickerStyle(MenuPickerStyle())
  315. .onChange(of: selectedDisableSmbOption) { _, newValue in
  316. switch newValue {
  317. case .dontDisable:
  318. state.smbIsOff = false
  319. state.smbIsScheduledOff = false
  320. case .disable:
  321. state.smbIsOff = true
  322. state.smbIsScheduledOff = false
  323. case .disableOnSchedule:
  324. state.smbIsOff = false
  325. state.smbIsScheduledOff = true
  326. }
  327. }
  328. if state.smbIsScheduledOff {
  329. // First Hour SMBs Are Disabled
  330. VStack {
  331. HStack {
  332. Text("From")
  333. Spacer()
  334. Text(
  335. is24HourFormat() ? format24Hour(Int(truncating: state.start as NSNumber)) + ":00" :
  336. convertTo12HourFormat(Int(truncating: state.start as NSNumber))
  337. )
  338. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  339. Spacer()
  340. Divider().frame(width: 1, height: 20)
  341. Spacer()
  342. Text("To")
  343. Spacer()
  344. Text(
  345. is24HourFormat() ? format24Hour(Int(truncating: state.end as NSNumber)) + ":00" :
  346. convertTo12HourFormat(Int(truncating: state.end as NSNumber))
  347. )
  348. .foregroundColor(!displayPickerDisableSmbSchedule ? .primary : .accentColor)
  349. Spacer()
  350. }
  351. .padding(.vertical, pad)
  352. .onTapGesture {
  353. displayPickerDisableSmbSchedule.toggle()
  354. }
  355. if displayPickerDisableSmbSchedule {
  356. HStack {
  357. // From Picker
  358. Picker(selection: Binding(
  359. get: { Int(truncating: state.start as NSNumber) },
  360. set: { state.start = Decimal($0) }
  361. ), label: Text("")) {
  362. ForEach(0 ..< 24, id: \.self) { hour in
  363. Text(is24HourFormat() ? format24Hour(hour) + ":00" : convertTo12HourFormat(hour))
  364. .tag(hour)
  365. }
  366. }
  367. .pickerStyle(WheelPickerStyle())
  368. .frame(maxWidth: .infinity)
  369. // To Picker
  370. Picker(selection: Binding(
  371. get: { Int(truncating: state.end as NSNumber) },
  372. set: { state.end = Decimal($0) }
  373. ), label: Text("")) {
  374. ForEach(0 ..< 24, id: \.self) { hour in
  375. Text(is24HourFormat() ? format24Hour(hour) + ":00" : convertTo12HourFormat(hour))
  376. .tag(hour)
  377. }
  378. }
  379. .pickerStyle(WheelPickerStyle())
  380. .frame(maxWidth: .infinity)
  381. }
  382. }
  383. }
  384. }
  385. }
  386. if !state.smbIsOff {
  387. VStack {
  388. Toggle(isOn: $state.advancedSettings) {
  389. Text("Override Max SMB Minutes")
  390. }
  391. .padding(.vertical, pad)
  392. if state.advancedSettings {
  393. // SMB Minutes Picker
  394. VStack {
  395. HStack {
  396. Text("SMB")
  397. Spacer()
  398. Text("\(state.smbMinutes.formatted(.number)) min")
  399. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  400. Spacer()
  401. Divider().frame(width: 1, height: 20)
  402. Spacer()
  403. Text("UAM")
  404. Spacer()
  405. Text("\(state.uamMinutes.formatted(.number)) min")
  406. .foregroundColor(!displayPickerSmbMinutes ? .primary : .accentColor)
  407. }
  408. .padding(.vertical, pad)
  409. .onTapGesture {
  410. displayPickerSmbMinutes.toggle()
  411. }
  412. if displayPickerSmbMinutes {
  413. HStack {
  414. Picker(selection: Binding(
  415. get: { Int(truncating: state.smbMinutes as NSNumber) },
  416. set: { state.smbMinutes = Decimal($0) }
  417. ), label: Text("")) {
  418. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  419. Text("\(minute) min").tag(minute)
  420. }
  421. }
  422. .pickerStyle(WheelPickerStyle())
  423. .frame(maxWidth: .infinity)
  424. Picker(selection: Binding(
  425. get: { Int(truncating: state.uamMinutes as NSNumber) },
  426. set: { state.uamMinutes = Decimal($0) }
  427. ), label: Text("")) {
  428. ForEach(Array(stride(from: 0, through: 180, by: 5)), id: \.self) { minute in
  429. Text("\(minute) min").tag(minute)
  430. }
  431. }
  432. .pickerStyle(WheelPickerStyle())
  433. .frame(maxWidth: .infinity)
  434. }
  435. }
  436. }
  437. }
  438. }
  439. }
  440. }
  441. .listRowBackground(Color.chart)
  442. }
  443. private var saveButton: some View {
  444. let (isInvalid, errorMessage) = isOverrideInvalid()
  445. return Group {
  446. Section(
  447. header:
  448. HStack {
  449. Spacer()
  450. Text(errorMessage ?? "").textCase(nil)
  451. .foregroundColor(colorScheme == .dark ? .orange : .accentColor)
  452. Spacer()
  453. },
  454. content: {
  455. Button(action: {
  456. Task {
  457. if state.indefinite { state.overrideDuration = 0 }
  458. state.isEnabled.toggle()
  459. await state.saveCustomOverride()
  460. await state.resetStateVariables()
  461. dismiss()
  462. }
  463. }, label: {
  464. Text("Enact Override")
  465. })
  466. .disabled(isInvalid)
  467. .frame(maxWidth: .infinity, alignment: .center)
  468. .tint(.white)
  469. }
  470. ).listRowBackground(isInvalid ? Color(.systemGray4) : Color(.systemBlue))
  471. Section {
  472. Button(action: {
  473. Task {
  474. await state.saveOverridePreset()
  475. dismiss()
  476. }
  477. }, label: {
  478. Text("Save as Preset")
  479. })
  480. .disabled(isInvalid)
  481. .frame(maxWidth: .infinity, alignment: .center)
  482. .tint(.white)
  483. }
  484. .listRowBackground(
  485. isInvalid ? Color(.systemGray4) : Color.secondary
  486. )
  487. }
  488. }
  489. private func totalDurationInMinutes() -> Int {
  490. let durationTotal = (durationHours * 60) + durationMinutes
  491. return max(0, durationTotal)
  492. }
  493. private func isOverrideInvalid() -> (Bool, String?) {
  494. let noDurationSpecified = !state.indefinite && state.overrideDuration == 0
  495. let targetZeroWithOverride = state.shouldOverrideTarget && state.target == 0
  496. let allSettingsDefault = state.overridePercentage == 100 && !state.shouldOverrideTarget &&
  497. !state.advancedSettings && !state.smbIsOff && !state.smbIsScheduledOff
  498. if noDurationSpecified {
  499. return (true, "Enable indefinitely or set a duration.")
  500. }
  501. if targetZeroWithOverride {
  502. return (true, "Target glucose is out of range (\(state.units == .mgdL ? "72-270" : "4-14")).")
  503. }
  504. if allSettingsDefault {
  505. return (true, "All settings are at default values.")
  506. }
  507. return (false, nil)
  508. }
  509. func generateTargetPickerValues() -> [Decimal] {
  510. var values: [Decimal] = []
  511. var currentValue: Double = 72
  512. let step = Double(targetStep)
  513. // Adjust currentValue to be divisible by targetStep
  514. let remainder = currentValue.truncatingRemainder(dividingBy: step)
  515. if remainder != 0 {
  516. // Move currentValue up to the next value divisible by targetStep
  517. currentValue += (step - remainder)
  518. }
  519. // Now generate the picker values starting from currentValue
  520. while currentValue <= 270 {
  521. values.append(Decimal(currentValue))
  522. currentValue += step
  523. }
  524. // Glucose values are stored as mg/dl values, so Integers.
  525. // Filter out duplicate values when rounded to 1 decimal place.
  526. if state.units == .mmolL {
  527. // Use a Set to track unique values rounded to 1 decimal
  528. var uniqueRoundedValues = Set<String>()
  529. values = values.filter { value in
  530. let roundedValue = String(format: "%.1f", NSDecimalNumber(decimal: value.asMmolL).doubleValue)
  531. return uniqueRoundedValues.insert(roundedValue).inserted
  532. }
  533. }
  534. return values
  535. }
  536. }
  537. // Function to check if the phone is using 24-hour format
  538. func is24HourFormat() -> Bool {
  539. let formatter = DateFormatter()
  540. formatter.locale = Locale.current
  541. formatter.dateStyle = .none
  542. formatter.timeStyle = .short
  543. let dateString = formatter.string(from: Date())
  544. return !dateString.contains("AM") && !dateString.contains("PM")
  545. }
  546. // Helper function to convert hours to AM/PM format
  547. func convertTo12HourFormat(_ hour: Int) -> String {
  548. let formatter = DateFormatter()
  549. formatter.dateFormat = "h a"
  550. // Create a date from the hour and format it to AM/PM
  551. let calendar = Calendar.current
  552. let components = DateComponents(hour: hour)
  553. let date = calendar.date(from: components) ?? Date()
  554. return formatter.string(from: date)
  555. }
  556. // Helper function to format 24-hour numbers as two digits
  557. func format24Hour(_ hour: Int) -> String {
  558. String(format: "%02d", hour)
  559. }
  560. func formatHrMin(_ durationInMinutes: Int) -> String {
  561. let hours = durationInMinutes / 60
  562. let minutes = durationInMinutes % 60
  563. switch (hours, minutes) {
  564. case let (0, m):
  565. return "\(m) min"
  566. case let (h, 0):
  567. return "\(h) hr"
  568. default:
  569. return "\(hours) hr \(minutes) min"
  570. }
  571. }
  572. struct RadioButton: View {
  573. var isSelected: Bool
  574. var label: String
  575. var action: () -> Void
  576. var body: some View {
  577. Button(action: {
  578. action()
  579. }) {
  580. HStack {
  581. Image(systemName: isSelected ? "largecircle.fill.circle" : "circle")
  582. Text(label) // Add label inside the button to make it tappable
  583. }
  584. }
  585. .buttonStyle(PlainButtonStyle())
  586. }
  587. }