DataTableRootView.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import CoreData
  2. import SwiftUI
  3. import Swinject
  4. extension DataTable {
  5. struct RootView: BaseView {
  6. let resolver: Resolver
  7. @StateObject var state = StateModel()
  8. @State private var isRemoveHistoryItemAlertPresented: Bool = false
  9. @State private var alertTitle: String = ""
  10. @State private var alertMessage: String = ""
  11. @State private var alertTreatmentToDelete: PumpEventStored?
  12. @State private var alertCarbEntryToDelete: CarbEntryStored?
  13. @State private var alertGlucoseToDelete: GlucoseStored?
  14. @State private var showAlert = false
  15. @State private var showFutureEntries: Bool = false // default to hide future entries
  16. @State private var showManualGlucose: Bool = false
  17. @State private var isAmountUnconfirmed: Bool = true
  18. @Environment(\.colorScheme) var colorScheme
  19. @Environment(\.managedObjectContext) var context
  20. @FetchRequest(
  21. entity: GlucoseStored.entity(),
  22. sortDescriptors: [NSSortDescriptor(keyPath: \GlucoseStored.date, ascending: false)],
  23. predicate: NSPredicate.predicateForOneDayAgo,
  24. animation: .bouncy
  25. ) var glucoseStored: FetchedResults<GlucoseStored>
  26. @FetchRequest(
  27. entity: PumpEventStored.entity(),
  28. sortDescriptors: [NSSortDescriptor(keyPath: \PumpEventStored.timestamp, ascending: false)],
  29. predicate: NSPredicate.pumpHistoryLast24h,
  30. animation: .bouncy
  31. ) var pumpEventStored: FetchedResults<PumpEventStored>
  32. @FetchRequest(
  33. entity: CarbEntryStored.entity(),
  34. sortDescriptors: [NSSortDescriptor(keyPath: \CarbEntryStored.date, ascending: false)],
  35. predicate: NSPredicate.predicateForOneDayAgo,
  36. animation: .bouncy
  37. ) var carbEntryStored: FetchedResults<CarbEntryStored>
  38. private var insulinFormatter: NumberFormatter {
  39. let formatter = NumberFormatter()
  40. formatter.numberStyle = .decimal
  41. formatter.maximumFractionDigits = 2
  42. return formatter
  43. }
  44. private var glucoseFormatter: NumberFormatter {
  45. let formatter = NumberFormatter()
  46. formatter.numberStyle = .decimal
  47. if state.units == .mmolL {
  48. formatter.maximumFractionDigits = 1
  49. formatter.minimumFractionDigits = 1
  50. formatter.roundingMode = .halfUp
  51. } else {
  52. formatter.maximumFractionDigits = 0
  53. }
  54. return formatter
  55. }
  56. private var manualGlucoseFormatter: NumberFormatter {
  57. let formatter = NumberFormatter()
  58. formatter.numberStyle = .decimal
  59. formatter.maximumFractionDigits = 0
  60. if state.units == .mmolL {
  61. formatter.minimumFractionDigits = 0
  62. formatter.maximumFractionDigits = 1
  63. }
  64. formatter.roundingMode = .halfUp
  65. return formatter
  66. }
  67. private var dateFormatter: DateFormatter {
  68. let formatter = DateFormatter()
  69. formatter.timeStyle = .short
  70. return formatter
  71. }
  72. private var numberFormatter: NumberFormatter {
  73. let formatter = NumberFormatter()
  74. formatter.numberStyle = .decimal
  75. formatter.maximumFractionDigits = 2
  76. return formatter
  77. }
  78. private var color: LinearGradient {
  79. colorScheme == .dark ? LinearGradient(
  80. gradient: Gradient(colors: [
  81. Color.bgDarkBlue,
  82. Color.bgDarkerDarkBlue
  83. ]),
  84. startPoint: .top,
  85. endPoint: .bottom
  86. )
  87. :
  88. LinearGradient(
  89. gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
  90. startPoint: .top,
  91. endPoint: .bottom
  92. )
  93. }
  94. var body: some View {
  95. ZStack(alignment: .center, content: {
  96. VStack {
  97. Picker("Mode", selection: $state.mode) {
  98. ForEach(
  99. Mode.allCases.indexed(),
  100. id: \.1
  101. ) { index, item in
  102. Text(item.name).tag(index)
  103. }
  104. }
  105. .pickerStyle(SegmentedPickerStyle())
  106. .padding(.horizontal)
  107. Form {
  108. switch state.mode {
  109. case .treatments: treatmentsList
  110. case .glucose: glucoseList
  111. case .meals: mealsList
  112. }
  113. }.scrollContentBackground(.hidden)
  114. .background(color)
  115. }.blur(radius: state.waitForSuggestion ? 8 : 0)
  116. // Show custom progress view
  117. /// don't show it if glucose is stale as it will block the UI
  118. if state.waitForSuggestion && state.isGlucoseDataFresh(glucoseStored.first?.date) {
  119. CustomProgressView(text: progressText.rawValue)
  120. }
  121. })
  122. .background(color)
  123. .onAppear(perform: configureView)
  124. .onDisappear {
  125. state.carbEntryDeleted = false
  126. state.insulinEntryDeleted = false
  127. }
  128. .navigationTitle("History")
  129. .navigationBarTitleDisplayMode(.large)
  130. .toolbar {
  131. ToolbarItem(placement: .topBarLeading, content: {
  132. Button(
  133. action: { state.showModal(for: .statistics) },
  134. label: {
  135. HStack {
  136. Text("Statistics")
  137. }
  138. }
  139. )
  140. })
  141. }
  142. .toolbar {
  143. ToolbarItem(placement: .topBarTrailing, content: {
  144. addButton({
  145. showManualGlucose = true
  146. state.manualGlucose = 0
  147. })
  148. })
  149. }
  150. .sheet(isPresented: $showManualGlucose) {
  151. addGlucoseView()
  152. }
  153. }
  154. @ViewBuilder func addButton(_ action: @escaping () -> Void) -> some View {
  155. Button(
  156. action: action,
  157. label: {
  158. HStack {
  159. Text("Add Glucose")
  160. Image(systemName: "plus")
  161. .font(.system(size: 20))
  162. }
  163. }
  164. )
  165. }
  166. private var progressText: ProgressText {
  167. switch (state.carbEntryDeleted, state.insulinEntryDeleted) {
  168. case (true, false):
  169. return .updatingCOB
  170. case(false, true):
  171. return .updatingIOB
  172. default:
  173. return .updatingHistory
  174. }
  175. }
  176. private var logGlucoseButton: some View {
  177. Button(
  178. action: {
  179. showManualGlucose = true
  180. state.manualGlucose = 0
  181. },
  182. label: {
  183. Text("Log Glucose")
  184. .foregroundColor(Color.accentColor)
  185. Image(systemName: "plus")
  186. .foregroundColor(Color.accentColor)
  187. }
  188. ).buttonStyle(.borderless)
  189. }
  190. private var treatmentsList: some View {
  191. List {
  192. HStack {
  193. Text("Insulin").foregroundStyle(.secondary)
  194. Spacer()
  195. Text("Time").foregroundStyle(.secondary)
  196. }
  197. if !pumpEventStored.isEmpty {
  198. ForEach(pumpEventStored.filter({ !showFutureEntries ? $0.timestamp ?? Date() <= Date() : true })) { item in
  199. treatmentView(item)
  200. }
  201. } else {
  202. HStack {
  203. Text("No data.")
  204. }
  205. }
  206. }.listRowBackground(Color.chart)
  207. }
  208. private var mealsList: some View {
  209. List {
  210. HStack {
  211. Text("Type").foregroundStyle(.secondary)
  212. Spacer()
  213. filterEntriesButton
  214. }
  215. if !carbEntryStored.isEmpty {
  216. ForEach(carbEntryStored.filter({ !showFutureEntries ? $0.date ?? Date() <= Date() : true })) { item in
  217. mealView(item)
  218. }
  219. } else {
  220. HStack {
  221. Text("No data.")
  222. }
  223. }
  224. }.listRowBackground(Color.chart)
  225. }
  226. private var glucoseList: some View {
  227. List {
  228. HStack {
  229. Text("Values").foregroundStyle(.secondary)
  230. Spacer()
  231. Text("Time").foregroundStyle(.secondary)
  232. }
  233. if !glucoseStored.isEmpty {
  234. ForEach(glucoseStored) { glucose in
  235. HStack {
  236. Text(formatGlucose(Decimal(glucose.glucose), isManual: glucose.isManual))
  237. /// check for manual glucose
  238. if glucose.isManual {
  239. Image(systemName: "drop.fill").symbolRenderingMode(.monochrome).foregroundStyle(.red)
  240. } else {
  241. Text("\(glucose.directionEnum?.symbol ?? "--")")
  242. }
  243. Spacer()
  244. Text(dateFormatter.string(from: glucose.date ?? Date()))
  245. }.swipeActions {
  246. Button(
  247. "Delete",
  248. systemImage: "trash.fill",
  249. role: .none,
  250. action: {
  251. alertGlucoseToDelete = glucose
  252. alertTitle = "Delete Glucose?"
  253. alertMessage = dateFormatter
  254. .string(from: glucose.date ?? Date()) + ", " +
  255. (numberFormatter.string(for: glucose.glucose) ?? "0")
  256. isRemoveHistoryItemAlertPresented = true
  257. }
  258. ).tint(.red)
  259. }
  260. .alert(
  261. Text(NSLocalizedString(alertTitle, comment: "")),
  262. isPresented: $isRemoveHistoryItemAlertPresented
  263. ) {
  264. Button("Cancel", role: .cancel) {}
  265. Button("Delete", role: .destructive) {
  266. guard let glucoseToDelete = alertGlucoseToDelete else {
  267. debug(.default, "Cannot gracefully unwrap alertCarbEntryToDelete!")
  268. return
  269. }
  270. let glucoseToDeleteObjectID = glucoseToDelete.objectID
  271. state.invokeGlucoseDeletionTask(glucoseToDeleteObjectID)
  272. }
  273. } message: {
  274. Text("\n" + NSLocalizedString(alertMessage, comment: ""))
  275. }
  276. }
  277. } else {
  278. HStack {
  279. Text("No data.")
  280. }
  281. }
  282. }.listRowBackground(Color.chart)
  283. .alert(isPresented: $showAlert) {
  284. Alert(title: Text("Error"), message: Text(alertMessage), dismissButton: .default(Text("OK")))
  285. }
  286. }
  287. private func deleteGlucose(at offsets: IndexSet) {
  288. for index in offsets {
  289. let glucoseToDelete = glucoseStored[index]
  290. context.delete(glucoseToDelete)
  291. }
  292. do {
  293. try context.save()
  294. debugPrint("Data Table Root View: \(#function) \(DebuggingIdentifiers.succeeded) deleted glucose from core data")
  295. } catch {
  296. debugPrint(
  297. "Data Table Root View: \(#function) \(DebuggingIdentifiers.failed) error while deleting glucose from core data"
  298. )
  299. alertMessage = "Failed to delete glucose data: \(error.localizedDescription)"
  300. showAlert = true
  301. }
  302. }
  303. @ViewBuilder private func addGlucoseView() -> some View {
  304. let limitLow: Decimal = state.units == .mmolL ? 0.8 : 14
  305. let limitHigh: Decimal = state.units == .mmolL ? 40 : 720
  306. NavigationView {
  307. VStack {
  308. Form {
  309. Section {
  310. HStack {
  311. Text("New Glucose")
  312. TextFieldWithToolBar(
  313. text: $state.manualGlucose,
  314. placeholder: " ... ",
  315. shouldBecomeFirstResponder: true,
  316. numberFormatter: manualGlucoseFormatter
  317. )
  318. Text(state.units.rawValue).foregroundStyle(.secondary)
  319. }
  320. }.listRowBackground(Color.chart)
  321. Section {
  322. HStack {
  323. Button {
  324. state.addManualGlucose()
  325. isAmountUnconfirmed = false
  326. showManualGlucose = false
  327. state.mode = .glucose
  328. }
  329. label: { Text("Save") }
  330. .frame(maxWidth: .infinity, alignment: .center)
  331. .disabled(state.manualGlucose < limitLow || state.manualGlucose > limitHigh)
  332. }
  333. }
  334. .listRowBackground(
  335. state.manualGlucose < limitLow || state
  336. .manualGlucose > limitHigh ? Color(.systemGray4) : Color(.systemBlue)
  337. )
  338. .tint(.white)
  339. }.scrollContentBackground(.hidden).background(color)
  340. }
  341. .onAppear(perform: configureView)
  342. .navigationTitle("Add Glucose")
  343. .navigationBarTitleDisplayMode(.inline)
  344. .toolbar {
  345. ToolbarItem(placement: .topBarLeading) {
  346. Button("Close") {
  347. showManualGlucose = false
  348. }
  349. }
  350. }
  351. }
  352. }
  353. private var filterEntriesButton: some View {
  354. Button(action: { showFutureEntries.toggle() }, label: {
  355. HStack {
  356. Text(showFutureEntries ? "Hide Future" : "Show Future")
  357. .foregroundColor(Color.secondary)
  358. Image(systemName: showFutureEntries ? "calendar.badge.minus" : "calendar.badge.plus")
  359. }.frame(maxWidth: .infinity, alignment: .trailing)
  360. }).buttonStyle(.borderless)
  361. }
  362. @ViewBuilder private func treatmentView(_ item: PumpEventStored) -> some View {
  363. HStack {
  364. if let bolus = item.bolus, let amount = bolus.amount {
  365. Image(systemName: "circle.fill").foregroundColor(Color.insulin)
  366. Text(bolus.isSMB ? "SMB" : item.type ?? "Bolus")
  367. Text((insulinFormatter.string(from: amount) ?? "0") + NSLocalizedString(" U", comment: "Insulin unit"))
  368. .foregroundColor(.secondary)
  369. if bolus.isExternal {
  370. Text(NSLocalizedString("External", comment: "External Insulin")).foregroundColor(.secondary)
  371. }
  372. } else if let tempBasal = item.tempBasal, let rate = tempBasal.rate {
  373. Image(systemName: "circle.fill").foregroundColor(Color.insulin.opacity(0.4))
  374. Text("Temp Basal")
  375. Text(
  376. (insulinFormatter.string(from: rate) ?? "0") +
  377. NSLocalizedString(" U/hr", comment: "Unit insulin per hour")
  378. )
  379. .foregroundColor(.secondary)
  380. if tempBasal.duration > 0 {
  381. Text("\(tempBasal.duration.string) min").foregroundColor(.secondary)
  382. }
  383. } else {
  384. Image(systemName: "circle.fill").foregroundColor(Color.loopGray)
  385. Text(item.type ?? "Pump Event")
  386. }
  387. Spacer()
  388. Text(dateFormatter.string(from: item.timestamp ?? Date())).moveDisabled(true)
  389. }
  390. .swipeActions {
  391. if item.bolus != nil {
  392. Button(
  393. "Delete",
  394. systemImage: "trash.fill",
  395. role: .none,
  396. action: {
  397. alertTreatmentToDelete = item
  398. alertTitle = "Delete Insulin?"
  399. alertMessage = dateFormatter
  400. .string(from: item.timestamp ?? Date()) + ", " +
  401. (insulinFormatter.string(from: item.bolus?.amount ?? 0) ?? "0") +
  402. NSLocalizedString(" U", comment: "Insulin unit")
  403. if let bolus = item.bolus {
  404. // Add text snippet, so that alert message is more descriptive for SMBs
  405. alertMessage += bolus.isSMB ? " SMB" : ""
  406. }
  407. isRemoveHistoryItemAlertPresented = true
  408. }
  409. ).tint(.red)
  410. }
  411. }
  412. .alert(
  413. Text(NSLocalizedString(alertTitle, comment: "")),
  414. isPresented: $isRemoveHistoryItemAlertPresented
  415. ) {
  416. Button("Cancel", role: .cancel) {}
  417. Button("Delete", role: .destructive) {
  418. guard let treatmentToDelete = alertTreatmentToDelete else {
  419. debug(.default, "Cannot gracefully unwrap alertTreatmentToDelete!")
  420. return
  421. }
  422. let treatmentObjectID = treatmentToDelete.objectID
  423. state.invokeInsulinDeletionTask(treatmentObjectID)
  424. }
  425. } message: {
  426. Text("\n" + NSLocalizedString(alertMessage, comment: ""))
  427. }
  428. }
  429. @ViewBuilder private func mealView(_ meal: CarbEntryStored) -> some View {
  430. VStack {
  431. HStack {
  432. if meal.isFPU {
  433. Image(systemName: "circle.fill").foregroundColor(Color.orange.opacity(0.5))
  434. Text("Fat / Protein")
  435. Text((numberFormatter.string(for: meal.carbs) ?? "0") + NSLocalizedString(" g", comment: "gram of carbs"))
  436. } else {
  437. Image(systemName: "circle.fill").foregroundColor(Color.loopYellow)
  438. Text("Carbs")
  439. Text(
  440. (numberFormatter.string(for: meal.carbs) ?? "0") +
  441. NSLocalizedString(" g", comment: "gram of carb equilvalents")
  442. )
  443. }
  444. Spacer()
  445. Text(dateFormatter.string(from: meal.date ?? Date()))
  446. .moveDisabled(true)
  447. }
  448. if let note = meal.note, note != "" {
  449. HStack {
  450. Image(systemName: "square.and.pencil")
  451. Text(note)
  452. Spacer()
  453. }.padding(.top, 5).foregroundColor(.secondary)
  454. }
  455. }
  456. .swipeActions {
  457. Button(
  458. "Delete",
  459. systemImage: "trash.fill",
  460. role: .none,
  461. action: {
  462. alertCarbEntryToDelete = meal
  463. if !meal.isFPU {
  464. alertTitle = "Delete Carbs?"
  465. alertMessage = dateFormatter
  466. .string(from: meal.date ?? Date()) + ", " + (numberFormatter.string(for: meal.carbs) ?? "0") +
  467. NSLocalizedString(" g", comment: "gram of carbs")
  468. } else {
  469. alertTitle = "Delete Carb Equivalents?"
  470. alertMessage = "All FPUs of the meal will be deleted."
  471. }
  472. isRemoveHistoryItemAlertPresented = true
  473. }
  474. ).tint(.red)
  475. }
  476. .alert(
  477. Text(NSLocalizedString(alertTitle, comment: "")),
  478. isPresented: $isRemoveHistoryItemAlertPresented
  479. ) {
  480. Button("Cancel", role: .cancel) {}
  481. Button("Delete", role: .destructive) {
  482. guard let carbEntryToDelete = alertCarbEntryToDelete else {
  483. debug(.default, "Cannot gracefully unwrap alertCarbEntryToDelete!")
  484. return
  485. }
  486. let treatmentObjectID = carbEntryToDelete.objectID
  487. state.invokeCarbDeletionTask(treatmentObjectID)
  488. }
  489. } message: {
  490. Text("\n" + NSLocalizedString(alertMessage, comment: ""))
  491. }
  492. }
  493. // MARK: - Format glucose
  494. private func formatGlucose(_ value: Decimal, isManual: Bool) -> String {
  495. let formatter = isManual ? manualGlucoseFormatter : glucoseFormatter
  496. let glucoseValue = state.units == .mmolL ? value.asMmolL : value
  497. let formattedValue = formatter.string(from: glucoseValue as NSNumber) ?? "--"
  498. return formattedValue
  499. }
  500. }
  501. }