TreatmentsRootView.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. import Charts
  2. import CoreData
  3. import LoopKitUI
  4. import SwiftUI
  5. import Swinject
  6. extension Treatments {
  7. struct RootView: BaseView {
  8. enum FocusedField {
  9. case carbs
  10. case fat
  11. case protein
  12. case bolus
  13. }
  14. @FocusState private var focusedField: FocusedField?
  15. let resolver: Resolver
  16. @State var state = StateModel()
  17. @State private var showPresetSheet = false
  18. @State private var autofocus: Bool = true
  19. @State private var calculatorDetent = PresentationDetent.large
  20. @State private var pushed: Bool = false
  21. @State private var debounce: DispatchWorkItem?
  22. private enum Config {
  23. static let dividerHeight: CGFloat = 2
  24. static let spacing: CGFloat = 3
  25. }
  26. @Environment(\.colorScheme) var colorScheme
  27. @Environment(AppState.self) var appState
  28. private var formatter: NumberFormatter {
  29. let formatter = NumberFormatter()
  30. formatter.numberStyle = .decimal
  31. formatter.maximumIntegerDigits = 2
  32. formatter.maximumFractionDigits = 2
  33. return formatter
  34. }
  35. private var mealFormatter: NumberFormatter {
  36. let formatter = NumberFormatter()
  37. formatter.numberStyle = .decimal
  38. formatter.maximumIntegerDigits = 3
  39. formatter.maximumFractionDigits = 0
  40. return formatter
  41. }
  42. private var gluoseFormatter: NumberFormatter {
  43. let formatter = NumberFormatter()
  44. formatter.numberStyle = .decimal
  45. if state.units == .mmolL {
  46. formatter.maximumIntegerDigits = 2
  47. formatter.maximumFractionDigits = 1
  48. } else {
  49. formatter.maximumIntegerDigits = 3
  50. formatter.maximumFractionDigits = 0
  51. }
  52. return formatter
  53. }
  54. private var fractionDigits: Int {
  55. if state.units == .mmolL {
  56. return 1
  57. } else { return 0 }
  58. }
  59. /// Handles macro input (carb, fat, protein) in a debounced fashion.
  60. func handleDebouncedInput() {
  61. debounce?.cancel()
  62. debounce = DispatchWorkItem { [self] in
  63. Task {
  64. state.insulinCalculated = await state.calculateInsulin()
  65. await state.updateForecasts()
  66. }
  67. }
  68. if let debounce = debounce {
  69. DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: debounce)
  70. }
  71. }
  72. @ViewBuilder private func proteinAndFat() -> some View {
  73. HStack {
  74. HStack {
  75. Text("Protein")
  76. TextFieldWithToolBar(
  77. text: $state.protein,
  78. placeholder: "0",
  79. keyboardType: .numberPad,
  80. numberFormatter: mealFormatter,
  81. showArrows: true,
  82. previousTextField: { focusedField = previousField(from: .protein) },
  83. nextTextField: { focusedField = nextField(from: .protein) }
  84. )
  85. .focused($focusedField, equals: .protein)
  86. Text("g").foregroundColor(.secondary)
  87. }
  88. Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
  89. HStack {
  90. Text("Fat")
  91. TextFieldWithToolBar(
  92. text: $state.fat,
  93. placeholder: "0",
  94. keyboardType: .numberPad,
  95. numberFormatter: mealFormatter,
  96. showArrows: true,
  97. previousTextField: { focusedField = previousField(from: .fat) },
  98. nextTextField: { focusedField = nextField(from: .fat) }
  99. )
  100. .focused($focusedField, equals: .fat)
  101. Text("g").foregroundColor(.secondary)
  102. }
  103. }
  104. }
  105. @ViewBuilder private func carbsTextField() -> some View {
  106. HStack {
  107. Text("Carbs")
  108. Spacer()
  109. TextFieldWithToolBar(
  110. text: $state.carbs,
  111. placeholder: "0",
  112. keyboardType: .numberPad,
  113. numberFormatter: mealFormatter,
  114. showArrows: true,
  115. previousTextField: { focusedField = previousField(from: .carbs) },
  116. nextTextField: { focusedField = nextField(from: .carbs) }
  117. )
  118. .focused($focusedField, equals: .carbs)
  119. .onChange(of: state.carbs) {
  120. handleDebouncedInput()
  121. }
  122. Text("g").foregroundColor(.secondary)
  123. }
  124. }
  125. /// Determines the next field to focus on based on the current focused field.
  126. ///
  127. /// This function handles the tab order navigation between input fields,
  128. /// taking into account whether fat/protein fields are visible based on user settings.
  129. ///
  130. /// - Parameter current: The currently focused field
  131. /// - Returns: The next field that should receive focus, or nil if there is no next field
  132. private func nextField(from current: FocusedField) -> FocusedField? {
  133. // If fat/protein fields are hidden, skip them in navigation
  134. let showFPU = state.useFPUconversion
  135. switch current {
  136. case .fat:
  137. return .bolus
  138. case .protein:
  139. return .fat
  140. case .carbs:
  141. return showFPU ? .protein : .bolus
  142. case .bolus:
  143. return .carbs
  144. }
  145. }
  146. /// Determines the previous field to focus on based on the current focused field.
  147. ///
  148. /// This function handles the reverse tab order navigation between input fields,
  149. /// taking into account whether fat/protein fields are visible based on user settings.
  150. ///
  151. /// - Parameter current: The currently focused field
  152. /// - Returns: The previous field that should receive focus, or nil if there is no previous field
  153. private func previousField(from current: FocusedField) -> FocusedField? {
  154. let showFPU = state.useFPUconversion
  155. switch current {
  156. case .fat:
  157. return .protein
  158. case .protein:
  159. return .carbs
  160. case .carbs:
  161. return .bolus
  162. case .bolus:
  163. return showFPU ? .fat : .carbs
  164. }
  165. }
  166. var body: some View {
  167. ZStack(alignment: .center) {
  168. VStack {
  169. List {
  170. Section {
  171. ForecastChart(state: state)
  172. .padding(.vertical)
  173. }.listRowBackground(Color.chart)
  174. Section {
  175. carbsTextField()
  176. if state.useFPUconversion {
  177. proteinAndFat()
  178. }
  179. // Time
  180. HStack {
  181. // Semi-hacky workaround to make sure the List renders the horizontal divider properly between the `Time` and `Note` rows within the Section
  182. HStack {
  183. Text("")
  184. Image(systemName: "clock").padding(.leading, -7)
  185. }
  186. Spacer()
  187. if !pushed {
  188. Button {
  189. pushed = true
  190. } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary)
  191. .padding(.trailing, 5)
  192. } else {
  193. Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) }
  194. label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless)
  195. DatePicker(
  196. "Time",
  197. selection: $state.date,
  198. displayedComponents: [.hourAndMinute]
  199. ).controlSize(.mini)
  200. .labelsHidden()
  201. Button {
  202. state.date = state.date.addingTimeInterval(15.minutes.timeInterval)
  203. }
  204. label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
  205. }
  206. }
  207. // Notes
  208. HStack {
  209. Image(systemName: "square.and.pencil")
  210. TextFieldWithToolBarString(
  211. text: $state.note,
  212. placeholder: String(localized: "Note..."),
  213. maxLength: 25
  214. )
  215. }
  216. }.listRowBackground(Color.chart)
  217. Section {
  218. if state.fattyMeals || state.sweetMeals {
  219. HStack(spacing: 10) {
  220. if state.fattyMeals {
  221. Toggle(isOn: $state.useFattyMealCorrectionFactor) {
  222. Text("Fatty Meal")
  223. }
  224. .toggleStyle(CheckboxToggleStyle())
  225. .font(.footnote)
  226. .onChange(of: state.useFattyMealCorrectionFactor) {
  227. Task {
  228. state.insulinCalculated = await state.calculateInsulin()
  229. if state.useFattyMealCorrectionFactor {
  230. state.useSuperBolus = false
  231. }
  232. }
  233. }
  234. }
  235. if state.sweetMeals {
  236. Toggle(isOn: $state.useSuperBolus) {
  237. Text("Super Bolus")
  238. }
  239. .toggleStyle(CheckboxToggleStyle())
  240. .font(.footnote)
  241. .onChange(of: state.useSuperBolus) {
  242. Task {
  243. state.insulinCalculated = await state.calculateInsulin()
  244. if state.useSuperBolus {
  245. state.useFattyMealCorrectionFactor = false
  246. }
  247. }
  248. }
  249. }
  250. }
  251. }
  252. HStack {
  253. HStack {
  254. Text("Recommendation")
  255. Button(action: {
  256. state.showInfo.toggle()
  257. }, label: {
  258. Image(systemName: "info.circle")
  259. })
  260. .foregroundStyle(.blue)
  261. .buttonStyle(PlainButtonStyle())
  262. }
  263. Spacer()
  264. Button {
  265. state.amount = state.insulinCalculated
  266. } label: {
  267. HStack {
  268. Text(
  269. formatter
  270. .string(from: Double(state.insulinCalculated) as NSNumber) ?? ""
  271. )
  272. Text(
  273. String(
  274. localized:
  275. " U",
  276. comment: "Unit in number of units delivered (keep the space character!)"
  277. )
  278. ).foregroundColor(.secondary)
  279. }
  280. }
  281. .disabled(state.insulinCalculated == 0 || state.amount == state.insulinCalculated)
  282. .buttonStyle(.bordered).padding(.trailing, -10)
  283. }
  284. HStack {
  285. Text("Bolus")
  286. Spacer()
  287. TextFieldWithToolBar(
  288. text: $state.amount,
  289. placeholder: "0",
  290. textColor: colorScheme == .dark ? .white : .blue,
  291. maxLength: 5,
  292. numberFormatter: formatter,
  293. showArrows: true,
  294. previousTextField: { focusedField = previousField(from: .bolus) },
  295. nextTextField: { focusedField = nextField(from: .bolus) }
  296. ).focused($focusedField, equals: .bolus)
  297. .onChange(of: state.amount) {
  298. Task {
  299. await state.updateForecasts()
  300. }
  301. }
  302. Text(" U").foregroundColor(.secondary)
  303. }
  304. HStack {
  305. Text("External Insulin")
  306. Spacer()
  307. Toggle("", isOn: $state.externalInsulin).toggleStyle(Checkbox())
  308. }
  309. }.listRowBackground(Color.chart)
  310. treatmentButton
  311. }
  312. .listSectionSpacing(sectionSpacing)
  313. }
  314. .blur(radius: state.isAwaitingDeterminationResult ? 5 : 0)
  315. if state.isAwaitingDeterminationResult {
  316. CustomProgressView(text: progressText.displayName)
  317. }
  318. }
  319. .padding(.top)
  320. .ignoresSafeArea(edges: .top)
  321. .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
  322. .blur(radius: state.showInfo ? 3 : 0)
  323. .navigationTitle("Treatments")
  324. .navigationBarTitleDisplayMode(.inline)
  325. .toolbar(content: {
  326. ToolbarItem(placement: .topBarLeading) {
  327. Button {
  328. state.hideModal()
  329. } label: {
  330. Text("Close")
  331. }
  332. }
  333. if state.displayPresets {
  334. ToolbarItem(placement: .topBarTrailing) {
  335. Button(action: {
  336. showPresetSheet = true
  337. }, label: {
  338. HStack {
  339. Text("Presets")
  340. Image(systemName: "plus")
  341. }
  342. })
  343. }
  344. }
  345. })
  346. .onAppear {
  347. configureView {
  348. state.isActive = true
  349. Task { @MainActor in
  350. state.insulinCalculated = await state.calculateInsulin()
  351. }
  352. }
  353. }
  354. .onDisappear {
  355. state.isActive = false
  356. state.addButtonPressed = false
  357. }
  358. .sheet(isPresented: $state.showInfo) {
  359. PopupView(state: state)
  360. }
  361. .sheet(isPresented: $showPresetSheet, onDismiss: {
  362. showPresetSheet = false
  363. }) {
  364. MealPresetView(state: state)
  365. }
  366. .alert("Determination Failed", isPresented: $state.showDeterminationFailureAlert) {
  367. Button("OK", role: .cancel) {
  368. state.hideModal()
  369. }
  370. } message: {
  371. Text("Failed to update COB/IOB: \(state.determinationFailureMessage)")
  372. }
  373. }
  374. var progressText: ProgressText {
  375. switch (state.amount > 0, state.carbs > 0) {
  376. case (true, true):
  377. return .updatingIOBandCOB
  378. case (false, true):
  379. return .updatingCOB
  380. case (true, false):
  381. return .updatingIOB
  382. default:
  383. return .updatingTreatments
  384. }
  385. }
  386. @State private var showConfirmDialogForBolusing = false
  387. private var bolusWarning: (shouldConfirm: Bool, warningMessage: String) {
  388. let isGlucoseVeryLow = state.currentBG < 54
  389. let isForecastVeryLow = state.minPredBG < 54
  390. // Only warn when enacting a bolus via pump
  391. guard !state.externalInsulin, state.amount > 0 else {
  392. return (false, "")
  393. }
  394. let warningMessage = isGlucoseVeryLow ? String(localized: "Glucose is very low.") :
  395. isForecastVeryLow ? String(localized: "Glucose forecast is very low.") :
  396. ""
  397. let shouldConfirm = state.confirmBolus && (isGlucoseVeryLow || isForecastVeryLow)
  398. return (shouldConfirm, warningMessage)
  399. }
  400. var treatmentButton: some View {
  401. var treatmentButtonBackground = Color(.systemBlue)
  402. if limitExceeded {
  403. treatmentButtonBackground = Color(.systemRed)
  404. } else if disableTaskButton {
  405. treatmentButtonBackground = Color(.systemGray)
  406. }
  407. return Section {
  408. Button {
  409. if bolusWarning.shouldConfirm {
  410. showConfirmDialogForBolusing = true
  411. } else {
  412. state.invokeTreatmentsTask()
  413. }
  414. } label: {
  415. HStack {
  416. if state.isBolusInProgress && state.amount > 0 &&
  417. !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
  418. {
  419. ProgressView()
  420. }
  421. taskButtonLabel
  422. }
  423. .font(.headline)
  424. .foregroundStyle(Color.white)
  425. .frame(maxWidth: .infinity, alignment: .center)
  426. .frame(height: 35)
  427. }
  428. .disabled(disableTaskButton)
  429. .listRowBackground(treatmentButtonBackground)
  430. .shadow(radius: 3)
  431. .clipShape(RoundedRectangle(cornerRadius: 8))
  432. .confirmationDialog(
  433. bolusWarning.warningMessage + " Bolus \(state.amount.description) U?",
  434. isPresented: $showConfirmDialogForBolusing,
  435. titleVisibility: .visible
  436. ) {
  437. Button("Cancel", role: .cancel) {}
  438. Button(
  439. bolusWarning.warningMessage.isEmpty ? "Enact Bolus" : "Ignore Warning and Enact Bolus",
  440. role: bolusWarning.warningMessage.isEmpty ? nil : .destructive
  441. ) {
  442. state.invokeTreatmentsTask()
  443. }
  444. }
  445. } header: {
  446. if !bolusWarning.warningMessage.isEmpty {
  447. Text(bolusWarning.warningMessage)
  448. .textCase(nil)
  449. .font(.subheadline)
  450. .foregroundColor(Color.loopRed)
  451. .frame(maxWidth: .infinity, alignment: .center)
  452. .padding(.top, -22)
  453. }
  454. }
  455. }
  456. private var taskButtonLabel: some View {
  457. if pumpBolusLimitExceeded {
  458. return Text("Max Bolus of \(state.maxBolus.description) U Exceeded")
  459. } else if externalBolusLimitExceeded {
  460. return Text("Max External Bolus of \(state.maxExternal.description) U Exceeded")
  461. } else if carbLimitExceeded {
  462. return Text("Max Carbs of \(state.maxCarbs.description) g Exceeded")
  463. } else if fatLimitExceeded {
  464. return Text("Max Fat of \(state.maxFat.description) g Exceeded")
  465. } else if proteinLimitExceeded {
  466. return Text("Max Protein of \(state.maxProtein.description) g Exceeded")
  467. }
  468. let hasInsulin = state.amount > 0
  469. let hasCarbs = state.carbs > 0
  470. let hasFatOrProtein = state.fat > 0 || state.protein > 0
  471. let bolusString = state.externalInsulin ? String(localized: "External Insulin") : String(localized: "Enact Bolus")
  472. if state.isBolusInProgress && hasInsulin && !state.externalInsulin && (!hasCarbs || !hasFatOrProtein) {
  473. return Text("Bolus In Progress...")
  474. }
  475. switch (hasInsulin, hasCarbs, hasFatOrProtein) {
  476. case (true, true, true):
  477. return Text("Log Meal and \(bolusString)")
  478. case (true, true, false):
  479. return Text("Log Carbs and \(bolusString)")
  480. case (true, false, true):
  481. return Text("Log FPU and \(bolusString)")
  482. case (true, false, false):
  483. return Text(state.externalInsulin ? "Log External Insulin" : "Enact Bolus")
  484. case (false, true, true):
  485. return Text("Log Meal")
  486. case (false, true, false):
  487. return Text("Log Carbs")
  488. case (false, false, true):
  489. return Text("Log FPU")
  490. default:
  491. return Text("Continue Without Treatment")
  492. }
  493. }
  494. private var pumpBolusLimitExceeded: Bool {
  495. !state.externalInsulin && state.amount > state.maxBolus
  496. }
  497. private var externalBolusLimitExceeded: Bool {
  498. state.externalInsulin && state.amount > state.maxExternal
  499. }
  500. private var carbLimitExceeded: Bool {
  501. state.carbs > state.maxCarbs
  502. }
  503. private var fatLimitExceeded: Bool {
  504. state.fat > state.maxFat
  505. }
  506. private var proteinLimitExceeded: Bool {
  507. state.protein > state.maxProtein
  508. }
  509. private var limitExceeded: Bool {
  510. pumpBolusLimitExceeded || externalBolusLimitExceeded || carbLimitExceeded || fatLimitExceeded || proteinLimitExceeded
  511. }
  512. private var disableTaskButton: Bool {
  513. (
  514. state.isBolusInProgress && state
  515. .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
  516. ) || state
  517. .addButtonPressed || limitExceeded
  518. }
  519. }
  520. struct DividerDouble: View {
  521. var body: some View {
  522. VStack(spacing: 2) {
  523. Rectangle()
  524. .frame(height: 1)
  525. .foregroundColor(.gray.opacity(0.65))
  526. Rectangle()
  527. .frame(height: 1)
  528. .foregroundColor(.gray.opacity(0.65))
  529. }
  530. .frame(height: 4)
  531. .padding(.vertical)
  532. }
  533. }
  534. struct DividerCustom: View {
  535. var body: some View {
  536. Rectangle()
  537. .frame(height: 1)
  538. .foregroundColor(.gray.opacity(0.65))
  539. .padding(.vertical)
  540. }
  541. }
  542. }