LoopStatusSheetView.swift 9.6 KB

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