BolusRootView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import Charts
  2. import CoreData
  3. import LoopKitUI
  4. import SwiftUI
  5. import Swinject
  6. extension Bolus {
  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. @StateObject var state = StateModel()
  17. @State private var showPresetSheet = false
  18. @State private var autofocus: Bool = true
  19. @State private var calculatorDetent = PresentationDetent.medium
  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. private var formatter: NumberFormatter {
  28. let formatter = NumberFormatter()
  29. formatter.numberStyle = .decimal
  30. formatter.maximumFractionDigits = 2
  31. return formatter
  32. }
  33. private var mealFormatter: NumberFormatter {
  34. let formatter = NumberFormatter()
  35. formatter.numberStyle = .decimal
  36. formatter.maximumFractionDigits = 1
  37. return formatter
  38. }
  39. private var gluoseFormatter: NumberFormatter {
  40. let formatter = NumberFormatter()
  41. formatter.numberStyle = .decimal
  42. if state.units == .mmolL {
  43. formatter.maximumFractionDigits = 1
  44. } else { formatter.maximumFractionDigits = 0 }
  45. return formatter
  46. }
  47. private var fractionDigits: Int {
  48. if state.units == .mmolL {
  49. return 1
  50. } else { return 0 }
  51. }
  52. private var color: LinearGradient {
  53. colorScheme == .dark ? LinearGradient(
  54. gradient: Gradient(colors: [
  55. Color.bgDarkBlue,
  56. Color.bgDarkerDarkBlue
  57. ]),
  58. startPoint: .top,
  59. endPoint: .bottom
  60. )
  61. :
  62. LinearGradient(
  63. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  64. startPoint: .top,
  65. endPoint: .bottom
  66. )
  67. }
  68. /// Handles macro input (carb, fat, protein) in a debounced fashion.
  69. func handleDebouncedInput() {
  70. debounce?.cancel()
  71. debounce = DispatchWorkItem { [self] in
  72. state.insulinCalculated = state.calculateInsulin()
  73. Task {
  74. await state.updateForecasts()
  75. }
  76. }
  77. if let debounce = debounce {
  78. DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: debounce)
  79. }
  80. }
  81. @ViewBuilder private func proteinAndFat() -> some View {
  82. HStack {
  83. Text("Fat").foregroundColor(.orange)
  84. Spacer()
  85. TextFieldWithToolBar(
  86. text: $state.fat,
  87. placeholder: "0",
  88. keyboardType: .numberPad,
  89. numberFormatter: mealFormatter,
  90. previousTextField: { focusOnPreviousTextField(index: 2) },
  91. nextTextField: { focusOnNextTextField(index: 2) }
  92. ).focused($focusedField, equals: .fat)
  93. Text("g").foregroundColor(.secondary)
  94. }
  95. HStack {
  96. Text("Protein").foregroundColor(.red)
  97. Spacer()
  98. TextFieldWithToolBar(
  99. text: $state.protein,
  100. placeholder: "0",
  101. keyboardType: .numberPad,
  102. numberFormatter: mealFormatter,
  103. previousTextField: { focusOnPreviousTextField(index: 3) },
  104. nextTextField: { focusOnNextTextField(index: 3) }
  105. ).focused($focusedField, equals: .protein)
  106. Text("g").foregroundColor(.secondary)
  107. }
  108. }
  109. @ViewBuilder private func carbsTextField() -> some View {
  110. HStack {
  111. Text("Carbs").fontWeight(.semibold)
  112. Spacer()
  113. TextFieldWithToolBar(
  114. text: $state.carbs,
  115. placeholder: "0",
  116. keyboardType: .numberPad,
  117. numberFormatter: mealFormatter,
  118. previousTextField: { focusOnPreviousTextField(index: 1) },
  119. nextTextField: { focusOnNextTextField(index: 1) }
  120. ).focused($focusedField, equals: .carbs)
  121. .onChange(of: state.carbs) { _ in
  122. handleDebouncedInput()
  123. }
  124. Text("g").foregroundColor(.secondary)
  125. }
  126. }
  127. func focusOnPreviousTextField(index: Int) {
  128. switch index {
  129. case 2:
  130. focusedField = .carbs
  131. case 3:
  132. focusedField = .fat
  133. case 4:
  134. focusedField = .protein
  135. default:
  136. break
  137. }
  138. }
  139. func focusOnNextTextField(index: Int) {
  140. switch index {
  141. case 1:
  142. focusedField = .fat
  143. case 2:
  144. focusedField = .protein
  145. case 3:
  146. focusedField = .bolus
  147. default:
  148. break
  149. }
  150. }
  151. var body: some View {
  152. ZStack(alignment: .center) {
  153. VStack {
  154. Form {
  155. Section {
  156. carbsTextField()
  157. if state.useFPUconversion {
  158. proteinAndFat()
  159. }
  160. // Time
  161. HStack {
  162. Text("Time").foregroundStyle(Color.secondary)
  163. Spacer()
  164. if !pushed {
  165. Button {
  166. pushed = true
  167. } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary)
  168. .padding(.trailing, 5)
  169. } else {
  170. Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) }
  171. label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless)
  172. DatePicker(
  173. "Time",
  174. selection: $state.date,
  175. displayedComponents: [.hourAndMinute]
  176. ).controlSize(.mini)
  177. .labelsHidden()
  178. Button {
  179. state.date = state.date.addingTimeInterval(15.minutes.timeInterval)
  180. }
  181. label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless)
  182. }
  183. }
  184. }.listRowBackground(Color.chart)
  185. Section {
  186. HStack {
  187. Button(action: {
  188. state.showInfo.toggle()
  189. }, label: {
  190. Image(systemName: "info.circle")
  191. Text("Calculations")
  192. })
  193. .foregroundStyle(.blue)
  194. .font(.footnote)
  195. .buttonStyle(PlainButtonStyle())
  196. .frame(maxWidth: .infinity, alignment: .leading)
  197. if state.fattyMeals {
  198. Spacer()
  199. Toggle(isOn: $state.useFattyMealCorrectionFactor) {
  200. Text("Fatty Meal")
  201. }
  202. .toggleStyle(CheckboxToggleStyle())
  203. .font(.footnote)
  204. .onChange(of: state.useFattyMealCorrectionFactor) { _ in
  205. state.insulinCalculated = state.calculateInsulin()
  206. if state.useFattyMealCorrectionFactor {
  207. state.useSuperBolus = false
  208. }
  209. }
  210. }
  211. if state.sweetMeals {
  212. Spacer()
  213. Toggle(isOn: $state.useSuperBolus) {
  214. Text("Super Bolus")
  215. }
  216. .toggleStyle(CheckboxToggleStyle())
  217. .font(.footnote)
  218. .onChange(of: state.useSuperBolus) { _ in
  219. state.insulinCalculated = state.calculateInsulin()
  220. if state.useSuperBolus {
  221. state.useFattyMealCorrectionFactor = false
  222. }
  223. }
  224. }
  225. }
  226. HStack {
  227. Text("Recommended Bolus")
  228. Spacer()
  229. Text(
  230. formatter
  231. .string(from: Double(state.insulinCalculated) as NSNumber) ?? ""
  232. )
  233. Text(
  234. NSLocalizedString(
  235. " U",
  236. comment: "Unit in number of units delivered (keep the space character!)"
  237. )
  238. ).foregroundColor(.secondary)
  239. }.contentShape(Rectangle())
  240. .onTapGesture { state.amount = state.insulinCalculated }
  241. HStack {
  242. Text("Bolus")
  243. Spacer()
  244. TextFieldWithToolBar(
  245. text: $state.amount,
  246. placeholder: "0",
  247. textColor: colorScheme == .dark ? .white : .blue,
  248. maxLength: 5,
  249. numberFormatter: formatter,
  250. previousTextField: { focusOnPreviousTextField(index: 4) },
  251. nextTextField: { focusOnNextTextField(index: 4) }
  252. ).focused($focusedField, equals: .bolus)
  253. .onChange(of: state.amount) { _ in
  254. Task {
  255. await state.updateForecasts()
  256. }
  257. }
  258. Text(" U").foregroundColor(.secondary)
  259. }
  260. HStack {
  261. Text("External insulin")
  262. Spacer()
  263. Toggle("", isOn: $state.externalInsulin).toggleStyle(Checkbox())
  264. }
  265. }.listRowBackground(Color.chart)
  266. Section {
  267. ForeCastChart(state: state, units: $state.units)
  268. .padding(.vertical)
  269. }.listRowBackground(Color.chart)
  270. }
  271. }
  272. .safeAreaInset(edge: .bottom, spacing: 0) {
  273. stickyButton
  274. }.blur(radius: state.waitForSuggestion ? 5 : 0)
  275. if state.waitForSuggestion {
  276. CustomProgressView(text: progressText.rawValue)
  277. }
  278. }
  279. .scrollContentBackground(.hidden).background(color)
  280. .blur(radius: state.showInfo ? 3 : 0)
  281. .navigationTitle("Treatments")
  282. .navigationBarTitleDisplayMode(.inline)
  283. .toolbar(content: {
  284. ToolbarItem(placement: .topBarLeading) {
  285. Button {
  286. state.hideModal()
  287. } label: {
  288. Text("Close")
  289. }
  290. }
  291. ToolbarItem(placement: .topBarTrailing) {
  292. Button(action: {
  293. showPresetSheet = true
  294. }, label: {
  295. HStack {
  296. Text("Presets")
  297. Image(systemName: "plus")
  298. }
  299. })
  300. }
  301. })
  302. .onAppear {
  303. configureView {
  304. state.insulinCalculated = state.calculateInsulin()
  305. }
  306. }
  307. .onDisappear {
  308. state.addButtonPressed = false
  309. }
  310. .sheet(isPresented: $state.showInfo) {
  311. PopupView(state: state)
  312. .presentationDetents(
  313. [.fraction(0.9), .large],
  314. selection: $calculatorDetent
  315. )
  316. }
  317. .sheet(isPresented: $showPresetSheet, onDismiss: {
  318. showPresetSheet = false
  319. }) {
  320. MealPresetView(state: state)
  321. }
  322. }
  323. var progressText: ProgressText {
  324. switch (state.amount > 0, state.carbs > 0) {
  325. case (true, true):
  326. return .updatingIOBandCOB
  327. case (false, true):
  328. return .updatingCOB
  329. case (true, false):
  330. return .updatingIOB
  331. default:
  332. return .updatingTreatments
  333. }
  334. }
  335. var stickyButton: some View {
  336. ZStack {
  337. Rectangle()
  338. .frame(width: UIScreen.main.bounds.width, height: 120).offset(y: 40)
  339. .shadow(
  340. color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) :
  341. Color.black.opacity(0.33),
  342. radius: 3
  343. )
  344. .foregroundStyle(Color.chart)
  345. Button {
  346. state.invokeTreatmentsTask()
  347. } label: {
  348. taskButtonLabel
  349. .font(.headline)
  350. .foregroundStyle(Color.white)
  351. .frame(maxWidth: .infinity, alignment: .center)
  352. .frame(minHeight: 50)
  353. }
  354. .disabled(disableTaskButton)
  355. .background(
  356. (state.externalInsulin ? externalBolusLimit : pumpBolusLimit) ? Color(.systemRed) :
  357. Color(.systemBlue)
  358. )
  359. .shadow(radius: 3)
  360. .clipShape(RoundedRectangle(cornerRadius: 8))
  361. .padding()
  362. .offset(y: 20)
  363. }
  364. }
  365. private var taskButtonLabel: some View {
  366. let hasInsulin = state.amount > 0
  367. let hasCarbs = state.carbs > 0
  368. let hasFatOrProtein = state.fat > 0 || state.protein > 0
  369. switch (hasInsulin, hasCarbs, hasFatOrProtein) {
  370. case (true, true, true):
  371. return Text(
  372. state
  373. .externalInsulin ? (
  374. externalBolusLimit ? "Manual bolus exceeds max bolus!" : "Log meal and external insulin"
  375. ) :
  376. (pumpBolusLimit ? "Pump bolus exceeds max bolus!" : "Log meal and enact bolus")
  377. )
  378. case (true, true, false):
  379. return Text(
  380. state
  381. .externalInsulin ?
  382. (externalBolusLimit ? "Manual bolus exceeds max bolus!" : "Log carbs and external insulin") :
  383. (pumpBolusLimit ? "Pump bolus exceeds max bolus!" : "Log carbs and enact bolus")
  384. )
  385. case (true, false, true):
  386. return Text(
  387. state
  388. .externalInsulin ?
  389. (externalBolusLimit ? "Manual bolus exceeds max bolus!" : "Log FPUs and external insulin") :
  390. (pumpBolusLimit ? "Pump bolus exceeds max bolus!" : "Log FPUs and enact bolus")
  391. )
  392. case (true, false, false):
  393. return Text(
  394. state
  395. .externalInsulin ? (externalBolusLimit ? "Manual bolus exceeds max bolus!" : "Log external insulin") :
  396. (pumpBolusLimit ? "Pump bolus exceeds max bolus!" : "Enact bolus")
  397. )
  398. case (false, true, true):
  399. return Text("Log meal")
  400. case (false, true, false):
  401. return Text("Log carbs")
  402. case (false, false, true):
  403. return Text("Log FPUs")
  404. default:
  405. return Text("Continue without treatment")
  406. }
  407. }
  408. private var pumpBolusLimit: Bool {
  409. state.amount > state.maxBolus
  410. }
  411. private var externalBolusLimit: Bool {
  412. state.amount > state.maxBolus * 3
  413. }
  414. private var disableTaskButton: Bool {
  415. state.addButtonPressed ||
  416. (state.amount > 0 ? (state.externalInsulin ? externalBolusLimit : pumpBolusLimit) : false)
  417. }
  418. }
  419. struct DividerDouble: View {
  420. var body: some View {
  421. VStack(spacing: 2) {
  422. Rectangle()
  423. .frame(height: 1)
  424. .foregroundColor(.gray.opacity(0.65))
  425. Rectangle()
  426. .frame(height: 1)
  427. .foregroundColor(.gray.opacity(0.65))
  428. }
  429. .frame(height: 4)
  430. .padding(.vertical)
  431. }
  432. }
  433. struct DividerCustom: View {
  434. var body: some View {
  435. Rectangle()
  436. .frame(height: 1)
  437. .foregroundColor(.gray.opacity(0.65))
  438. .padding(.vertical)
  439. }
  440. }
  441. }
  442. // fix iOS 15 bug
  443. struct ActivityIndicator: UIViewRepresentable {
  444. @Binding var isAnimating: Bool
  445. let style: UIActivityIndicatorView.Style
  446. func makeUIView(context _: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
  447. UIActivityIndicatorView(style: style)
  448. }
  449. func updateUIView(_ uiView: UIActivityIndicatorView, context _: UIViewRepresentableContext<ActivityIndicator>) {
  450. isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
  451. }
  452. }