AddOverrideForm.swift 25 KB

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