LoopStatusView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import SwiftUI
  2. struct LoopStatusView: View {
  3. @Environment(\.colorScheme) var colorScheme
  4. @Environment(AppState.self) var appState
  5. var state: Home.StateModel
  6. @State var sheetDetent = PresentationDetent.fraction(0.8)
  7. @State private var statusTitle: String = ""
  8. // Help Sheet
  9. @State var isHelpSheetPresented: Bool = false
  10. @State var helpSheetDetent = PresentationDetent.fraction(0.9)
  11. var body: some View {
  12. NavigationStack {
  13. VStack(alignment: .leading, spacing: 10) {
  14. Text("Current Loop Status").bold().padding(.top, 20)
  15. Text(statusTitle)
  16. .font(.headline)
  17. .padding(.horizontal, 12)
  18. .padding(.vertical, 6)
  19. .foregroundColor(statusBadgeTextColor)
  20. .background(statusBadgeColor)
  21. .clipShape(Capsule())
  22. if let errorMessage = state.errorMessage, let date = state.errorDate {
  23. Group {
  24. Text("Error During Algorithm Run at \(Formatter.dateFormatter.string(from: date))").font(.headline)
  25. Text(errorMessage).font(.caption)
  26. }.foregroundColor(.loopRed)
  27. }
  28. if let determination = state.determinationsFromPersistence.first {
  29. if determination.glucose == 400 {
  30. Text("Invalid CGM reading (HIGH).")
  31. .bold()
  32. .padding(.top)
  33. .foregroundStyle(Color.loopRed)
  34. Text("SMBs and Non-Zero Temp. Basal Rates are disabled.")
  35. .font(.subheadline)
  36. } else {
  37. Text("Latest Raw Algorithm Output")
  38. .bold()
  39. .padding(.top)
  40. Text(
  41. "Trio is currently using these metrics and values as determined by the oref algorithm:"
  42. )
  43. .font(.subheadline)
  44. let tags = !state.isSmoothingEnabled ? determination.reasonParts : determination
  45. .reasonParts + ["Smoothing: On"]
  46. TagCloudView(
  47. tags: tags,
  48. shouldParseToMmolL: state.units == .mmolL
  49. )
  50. Text("Current Algorithm Reasoning").bold().padding(.top)
  51. Text(
  52. self
  53. .parseReasonConclusion(
  54. determination.reasonConclusion,
  55. isMmolL: state.units == .mmolL
  56. )
  57. ).font(.subheadline)
  58. }
  59. } else {
  60. Text("No recent oref algorithm determination.")
  61. }
  62. Spacer()
  63. Button {
  64. state.isLoopStatusPresented.toggle()
  65. } label: {
  66. Text("Got it!")
  67. .frame(maxWidth: .infinity, alignment: .center)
  68. }
  69. .buttonStyle(.bordered)
  70. .padding(.top)
  71. }
  72. .padding(.vertical)
  73. .padding(.horizontal, 20)
  74. .presentationDetents(
  75. [.fraction(0.8), .large],
  76. selection: $sheetDetent
  77. )
  78. .ignoresSafeArea(edges: .top)
  79. .background(appState.trioBackgroundColor(for: colorScheme))
  80. .toolbar(content: {
  81. ToolbarItem(placement: .topBarTrailing) {
  82. Button(
  83. action: {
  84. isHelpSheetPresented.toggle()
  85. },
  86. label: {
  87. Image(systemName: "questionmark.circle")
  88. }
  89. )
  90. }
  91. })
  92. .onAppear {
  93. setStatusTitle()
  94. }
  95. .sheet(isPresented: $isHelpSheetPresented) {
  96. NavigationStack {
  97. List {
  98. DefinitionRow(term: "Placeholder for Algo Term", definition: Text("Lorem Ipsum Dolor Sit Amet"))
  99. }
  100. .navigationBarTitle("Help", displayMode: .inline)
  101. Button { isHelpSheetPresented.toggle() }
  102. label: { Text("Got it!").bold().frame(maxWidth: .infinity, minHeight: 44, alignment: .center) }
  103. .buttonStyle(.bordered)
  104. .padding(.top)
  105. }
  106. .padding()
  107. .presentationDetents(
  108. [.fraction(0.9), .large],
  109. selection: $helpSheetDetent
  110. ).scrollContentBackground(.hidden)
  111. }
  112. }
  113. .scrollContentBackground(.hidden)
  114. }
  115. private var statusBadgeColor: Color {
  116. guard let determination = state.determinationsFromPersistence.first, determination.timestamp != nil
  117. else {
  118. // previously the .timestamp property was used here because this only gets updated when the reportenacted function in the aps manager gets called
  119. return .secondary
  120. }
  121. let delta = state.timerDate.timeIntervalSince(state.lastLoopDate) - 30
  122. if delta <= 5.minutes.timeInterval {
  123. guard determination.timestamp != nil else {
  124. return .loopYellow
  125. }
  126. return .loopGreen
  127. } else if delta <= 10.minutes.timeInterval {
  128. return .loopYellow
  129. } else {
  130. return .loopRed
  131. }
  132. }
  133. private var statusBadgeTextColor: Color {
  134. statusBadgeColor == .secondary || statusBadgeColor == .loopYellow ? .black :
  135. .white
  136. }
  137. private func setStatusTitle() {
  138. if let determination = state.determinationsFromPersistence.first {
  139. statusTitle =
  140. "Enacted at \(Formatter.dateFormatter.string(from: determination.deliverAt ?? Date()))"
  141. } else {
  142. statusTitle = "Not enacted."
  143. }
  144. }
  145. // TODO: Consolidate all mmol parsing methods (in TagCloudView, NightscoutManager and HomeRootView) to one central func
  146. private func parseReasonConclusion(_ reasonConclusion: String, isMmolL: Bool) -> String {
  147. let patterns = [
  148. "minGuardBG\\s*-?\\d+\\.?\\d*<-?\\d+\\.?\\d*", // minGuardBG x<y
  149. "Eventual BG\\s*-?\\d+\\.?\\d*\\s*>=\\s*-?\\d+\\.?\\d*", // Eventual BG x >= target
  150. "Eventual BG\\s*-?\\d+\\.?\\d*\\s*<\\s*-?\\d+\\.?\\d*", // Eventual BG x < target
  151. "(\\S+)\\s+(-?\\d+\\.?\\d*)\\s*>\\s*(\\d+)%\\s+of\\s+BG\\s+(-?\\d+\\.?\\d*)" // maxDelta x > y% of BG z
  152. ]
  153. let pattern = patterns.joined(separator: "|")
  154. let regex = try! NSRegularExpression(pattern: pattern)
  155. func convertToMmolL(_ value: String) -> String {
  156. if let glucoseValue = Double(value.replacingOccurrences(of: "[^\\d.-]", with: "", options: .regularExpression)) {
  157. let mmolValue = Decimal(glucoseValue).asMmolL
  158. return mmolValue.description
  159. }
  160. return value
  161. }
  162. let matches = regex.matches(
  163. in: reasonConclusion,
  164. range: NSRange(reasonConclusion.startIndex..., in: reasonConclusion)
  165. )
  166. var updatedConclusion = reasonConclusion
  167. for match in matches.reversed() {
  168. guard let range = Range(match.range, in: reasonConclusion) else { continue }
  169. let matchedString = String(reasonConclusion[range])
  170. if isMmolL {
  171. if matchedString.contains("<"), matchedString.contains("Eventual BG"), !matchedString.contains("=") {
  172. // Handle "Eventual BG x < target" pattern
  173. let parts = matchedString.components(separatedBy: "<")
  174. if parts.count == 2 {
  175. let bgPart = parts[0].replacingOccurrences(of: "Eventual BG", with: "")
  176. .trimmingCharacters(in: .whitespaces)
  177. let targetValue = parts[1].trimmingCharacters(in: .whitespaces)
  178. let formattedBGPart = convertToMmolL(bgPart)
  179. let formattedTargetValue = convertToMmolL(targetValue)
  180. let formattedString = "Eventual BG \(formattedBGPart)<\(formattedTargetValue)"
  181. updatedConclusion.replaceSubrange(range, with: formattedString)
  182. }
  183. } else if matchedString.contains("<"), matchedString.contains("minGuardBG") {
  184. // Handle "minGuardBG x<y" pattern
  185. let parts = matchedString.components(separatedBy: "<")
  186. if parts.count == 2 {
  187. let firstValue = parts[0].trimmingCharacters(in: .whitespaces)
  188. let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
  189. let formattedFirstValue = convertToMmolL(firstValue)
  190. let formattedSecondValue = convertToMmolL(secondValue)
  191. let formattedString = "minGuardBG \(formattedFirstValue)<\(formattedSecondValue)"
  192. updatedConclusion.replaceSubrange(range, with: formattedString)
  193. }
  194. } else if matchedString.contains(">=") {
  195. // Handle "Eventual BG x >= target" pattern
  196. let parts = matchedString.components(separatedBy: " >= ")
  197. if parts.count == 2 {
  198. let firstValue = parts[0].replacingOccurrences(of: "Eventual BG", with: "")
  199. .trimmingCharacters(in: .whitespaces)
  200. let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
  201. let formattedFirstValue = convertToMmolL(firstValue)
  202. let formattedSecondValue = convertToMmolL(secondValue)
  203. let formattedString = "Eventual BG \(formattedFirstValue) >= \(formattedSecondValue)"
  204. updatedConclusion.replaceSubrange(range, with: formattedString)
  205. }
  206. } else if let localMatch = regex.firstMatch(
  207. in: matchedString,
  208. range: NSRange(matchedString.startIndex..., in: matchedString)
  209. ) {
  210. // Handle "maxDelta 37 > 20% of BG 95" style
  211. if match.numberOfRanges == 5 {
  212. let metric = String(matchedString[Range(localMatch.range(at: 1), in: matchedString)!])
  213. let firstValue = String(matchedString[Range(localMatch.range(at: 2), in: matchedString)!])
  214. let percentage = String(matchedString[Range(localMatch.range(at: 3), in: matchedString)!])
  215. let bgValue = String(matchedString[Range(localMatch.range(at: 4), in: matchedString)!])
  216. let formattedFirstValue = convertToMmolL(firstValue)
  217. let formattedBGValue = convertToMmolL(bgValue)
  218. let formattedString = "\(metric) \(formattedFirstValue) > \(percentage)% of BG \(formattedBGValue)"
  219. updatedConclusion.replaceSubrange(range, with: formattedString)
  220. }
  221. }
  222. } else {
  223. // When isMmolL is false, ensure the original value is retained without duplication
  224. updatedConclusion.replaceSubrange(range, with: matchedString)
  225. }
  226. }
  227. return updatedConclusion.capitalizingFirstLetter()
  228. }
  229. }