LoopStatusView.swift 13 KB

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