AddOverrideForm.swift 20 KB

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