LoopStatusView.swift 11 KB

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