AddOverrideForm.swift 23 KB

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