CurrentGlucoseView.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import CoreData
  2. import LoopKit
  3. import SwiftUI
  4. struct CurrentGlucoseView: View {
  5. let timerDate: Date
  6. let units: GlucoseUnits
  7. let alarm: GlucoseAlarm?
  8. let lowGlucose: Decimal
  9. let highGlucose: Decimal
  10. let cgmAvailable: Bool
  11. var currentGlucoseTarget: Decimal
  12. let glucoseColorScheme: GlucoseColorScheme
  13. let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
  14. /// Drives the outer ring.
  15. var cgmProgress: DeviceLifecycleProgress?
  16. /// CGM status highlight, rendered verbatim.
  17. var cgmStatus: CgmDisplayState?
  18. /// Sensor expiration — fallback tag when `cgmStatus` is nil.
  19. var cgmSensorExpiresAt: Date?
  20. /// Wall-clock end of the warmup window. Drives the warmup countdown tag.
  21. var cgmWarmupEndsAt: Date?
  22. @State private var rotationDegrees: Double = 0.0
  23. @State private var angularGradient = AngularGradient(colors: [
  24. Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
  25. Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
  26. Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
  27. Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961),
  28. Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902),
  29. Color(red: 0.7215686275, green: 0.3411764706, blue: 1)
  30. ], center: .center, startAngle: .degrees(270), endAngle: .degrees(-90))
  31. @Environment(\.colorScheme) var colorScheme
  32. private var deltaFormatter: NumberFormatter {
  33. let formatter = NumberFormatter()
  34. formatter.numberStyle = .decimal
  35. if units == .mmolL {
  36. formatter.maximumFractionDigits = 1
  37. formatter.minimumFractionDigits = 1
  38. formatter.roundingMode = .halfUp
  39. } else {
  40. formatter.maximumFractionDigits = 0
  41. }
  42. formatter.positivePrefix = " +"
  43. formatter.negativePrefix = " -"
  44. return formatter
  45. }
  46. var body: some View {
  47. let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
  48. if cgmAvailable {
  49. if let stale = stalenessState {
  50. // Compact error/transition state — same styles as the
  51. // empty-state "Add CGM" layout below, just colored by tier.
  52. VStack(alignment: .center, spacing: 12) {
  53. HStack {
  54. Image(systemName: stale.imageName)
  55. .font(.body)
  56. .imageScale(.large)
  57. .foregroundStyle(stale.color)
  58. }
  59. HStack {
  60. Text(stale.label)
  61. .font(.caption).bold()
  62. .foregroundStyle(stale.color)
  63. }
  64. }.frame(alignment: .top)
  65. } else {
  66. bobbleAndTag(triangleColor: triangleColor)
  67. }
  68. } else {
  69. VStack(alignment: .center, spacing: 12) {
  70. HStack
  71. {
  72. // no cgm defined so display a generic CGM
  73. Image(systemName: "sensor.tag.radiowaves.forward.fill").font(.body).imageScale(.large)
  74. }
  75. HStack {
  76. Text("Add CGM").font(.caption).bold()
  77. }
  78. }.frame(alignment: .top)
  79. }
  80. }
  81. @ViewBuilder private func bobbleAndTag(triangleColor: Color) -> some View {
  82. ZStack {
  83. if let progress = cgmProgress, shouldShowArc {
  84. SensorLifecycleArcView(
  85. progress: progress.percentComplete,
  86. progressState: progress.progressState
  87. )
  88. }
  89. TrendShape(gradient: angularGradient, color: triangleColor, showArrow: true)
  90. .rotationEffect(.degrees(rotationDegrees))
  91. VStack(alignment: .center) {
  92. bobbleContent()
  93. }
  94. }
  95. .overlay(alignment: .bottom) {
  96. // Overlay (not VStack) so the tag doesn't push siblings down;
  97. // hidden when the trend arrow rotates toward 6 o'clock.
  98. if let tag = tagLabel, !trendIsDownward {
  99. SensorStatusTagView(text: tag.text, theme: tag.theme, iconSystemName: tag.icon)
  100. .offset(y: 14)
  101. .zIndex(1)
  102. }
  103. }
  104. .onChange(of: glucose.last?.directionEnum) {
  105. withAnimation {
  106. switch glucose.last?.directionEnum {
  107. case .doubleUp,
  108. .singleUp,
  109. .tripleUp:
  110. rotationDegrees = -90
  111. case .fortyFiveUp:
  112. rotationDegrees = -45
  113. case .flat:
  114. rotationDegrees = 0
  115. case .fortyFiveDown:
  116. rotationDegrees = 45
  117. case .doubleDown,
  118. .singleDown,
  119. .tripleDown:
  120. rotationDegrees = 90
  121. case nil,
  122. .notComputable,
  123. .rateOutOfRange:
  124. rotationDegrees = 0
  125. default:
  126. rotationDegrees = 0
  127. }
  128. }
  129. }
  130. }
  131. private var delta: String {
  132. guard glucose.count >= 2 else {
  133. return "--"
  134. }
  135. var lastGlucose = Decimal(glucose.last?.glucose ?? 0)
  136. var secondLastGlucose = Decimal(glucose.first?.glucose ?? 0)
  137. if units == .mmolL {
  138. lastGlucose = lastGlucose.asMmolL
  139. secondLastGlucose = secondLastGlucose.asMmolL
  140. }
  141. let delta = lastGlucose - secondLastGlucose
  142. return deltaFormatter.string(from: delta as NSNumber) ?? "--"
  143. }
  144. @ViewBuilder private func bobbleContent() -> some View {
  145. HStack {
  146. if let glucoseValue = glucose.last?.glucose, isReadingFresh {
  147. let displayGlucose = units == .mgdL
  148. ? Decimal(glucoseValue).description
  149. : Decimal(glucoseValue).formattedAsMmolL
  150. Text(glucoseValue == 400 ? "HIGH" : displayGlucose)
  151. .font(.system(size: 40, weight: .bold, design: .rounded))
  152. .foregroundStyle(glucoseColor(for: glucoseValue))
  153. } else {
  154. Text("– –")
  155. .font(.system(size: 40, weight: .bold, design: .rounded))
  156. .foregroundStyle(.secondary)
  157. }
  158. }
  159. if isReadingFresh {
  160. HStack {
  161. let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date)
  162. Group {
  163. Text(minutesAgoString)
  164. Text(delta)
  165. }
  166. .font(.callout).fontWeight(.bold)
  167. .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
  168. }
  169. .frame(alignment: .top)
  170. }
  171. }
  172. /// Matches `APSManager`'s loop-input freshness gate — readings older than
  173. /// 12 minutes (one missed CGM transmission on a 5-min schedule) get
  174. /// masked to dashes. Handles warmup + sensor failure naturally: no
  175. /// fresh data → no number on the bobble.
  176. private var isReadingFresh: Bool {
  177. guard let date = glucose.last?.date else { return false }
  178. return Date().timeIntervalSince(date) < 12 * 60
  179. }
  180. private var trendIsDownward: Bool { rotationDegrees >= 90 }
  181. /// Error/transition states (sensor expired, sensor failure, signal loss,
  182. /// stabilizing, etc.) collapse the bobble to a compact symbol-over-label
  183. /// view — the bobble's purpose is to show glucose, so a fresh-reading-less
  184. /// bobble with arc + dashes obscures rather than informs. Warmup is the
  185. /// exception: it's a short, expected lifecycle phase and the arc + tag
  186. /// countdown carry useful info, so the bobble stays.
  187. private var stalenessState: (imageName: String, label: String, color: Color)? {
  188. guard !isReadingFresh,
  189. !isInWarmup,
  190. let status = cgmStatus,
  191. !status.imageName.isEmpty
  192. else { return nil }
  193. let color: Color
  194. switch status.status {
  195. case .critical: color = .loopRed
  196. case .warning: color = .orange
  197. case .normal: color = .secondary
  198. }
  199. // LibreLoop/G7 split labels with "\n" for their two-line native pills;
  200. // collapse to spaces so the compact label reads cleanly.
  201. let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
  202. return (status.imageName, oneLine, color)
  203. }
  204. /// Arc shown for warmup, the last 48 h of a time-based sensor (incl.
  205. /// grace period), or any non-normal state from a battery-based manager.
  206. private var shouldShowArc: Bool {
  207. if isInWarmup { return true }
  208. if let expiresAt = cgmSensorExpiresAt {
  209. return expiresAt.timeIntervalSinceNow <= 48 * 60 * 60
  210. }
  211. return cgmProgress?.progressState != .normalCGM
  212. }
  213. /// String sniff — loopandlearn LoopKit has no structural warmup flag.
  214. private var isInWarmup: Bool {
  215. guard let message = cgmStatus?.localizedMessage else { return false }
  216. let lowered = message.lowercased()
  217. return lowered.contains("warming up") || lowered.contains("warmup")
  218. }
  219. private var isStabilizing: Bool {
  220. cgmStatus?.localizedMessage.lowercased().contains("stabilizing") ?? false
  221. }
  222. /// Warmup → hourglass + countdown; stabilizing → hourglass + "Stabilizing"
  223. /// (no countdown — duration is sensor-driven); outside warmup/stabilizing
  224. /// the tag is gated to the same 48h window as the arc.
  225. private var tagLabel: (text: String, theme: SensorStatusTagTheme, icon: String?)? {
  226. if isInWarmup {
  227. let text: String
  228. if let endsAt = cgmWarmupEndsAt {
  229. text = SensorRemainingTimeFormatter.format(until: endsAt)
  230. } else {
  231. text = "Warming up"
  232. }
  233. return (text, .orange, "hourglass")
  234. }
  235. if isStabilizing {
  236. return ("Stabilizing", .orange, "hourglass")
  237. }
  238. guard shouldShowArc else { return nil }
  239. if let status = cgmStatus {
  240. // LibreLoop's cgmStatusHighlight uses "\n" to split two-line pill
  241. // labels ("Signal\nLoss", "Sensor\nWarmup"); we render in a
  242. // single-line tag, so collapse newlines to spaces here.
  243. let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
  244. return (oneLine, theme(for: status.status), nil)
  245. }
  246. if let expiresAt = cgmSensorExpiresAt {
  247. let text = SensorRemainingTimeFormatter.format(until: expiresAt)
  248. let theme: SensorStatusTagTheme
  249. switch cgmProgress?.progressState {
  250. case .critical: theme = .red
  251. case .warning: theme = .orange
  252. default: theme = .green
  253. }
  254. return (text, theme, nil)
  255. }
  256. return nil
  257. }
  258. private func theme(for status: CgmDisplayStatus) -> SensorStatusTagTheme {
  259. switch status {
  260. case .critical: return .red
  261. case .warning: return .orange
  262. case .normal: return .secondary
  263. }
  264. }
  265. private func glucoseColor(for glucoseValue: Int16) -> Color {
  266. // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
  267. let hardCodedLow = Decimal(55)
  268. let hardCodedHigh = Decimal(220)
  269. let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
  270. guard Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose else {
  271. return Color.primary
  272. }
  273. return Trio.getDynamicGlucoseColor(
  274. glucoseValue: Decimal(glucoseValue),
  275. highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
  276. lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
  277. targetGlucose: currentGlucoseTarget,
  278. glucoseColorScheme: glucoseColorScheme
  279. )
  280. }
  281. }
  282. struct Triangle: Shape {
  283. func path(in rect: CGRect) -> Path {
  284. var path = Path()
  285. path.move(to: CGPoint(x: rect.midX, y: rect.minY + 15))
  286. path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
  287. path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY), control: CGPoint(x: rect.midX, y: rect.midY + 10))
  288. path.closeSubpath()
  289. return path
  290. }
  291. }
  292. struct TrendShape: View {
  293. @Environment(\.colorScheme) var colorScheme
  294. let gradient: AngularGradient
  295. let color: Color
  296. var showArrow: Bool = true
  297. var body: some View {
  298. HStack(alignment: .center) {
  299. ZStack {
  300. Group {
  301. CircleShape(gradient: gradient)
  302. if showArrow {
  303. TriangleShape(color: color)
  304. }
  305. }.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33), radius: colorScheme == .dark ? 5 : 3)
  306. CircleShape(gradient: gradient)
  307. }
  308. }
  309. }
  310. }
  311. struct CircleShape: View {
  312. @Environment(\.colorScheme) var colorScheme
  313. let gradient: AngularGradient
  314. var body: some View {
  315. Circle()
  316. .stroke(gradient, lineWidth: 6)
  317. .background(Circle().fill(Color.chart))
  318. .frame(width: 130, height: 130)
  319. }
  320. }
  321. struct TriangleShape: View {
  322. let color: Color
  323. var body: some View {
  324. Triangle()
  325. .fill(color)
  326. .frame(width: 35, height: 35)
  327. .rotationEffect(.degrees(90))
  328. .offset(x: 85)
  329. }
  330. }