LoopStatusView.swift 14 KB

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