LiveActivityChartView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. //
  2. // LiveActivityChartView.swift
  3. // Trio
  4. //
  5. // Created by Cengiz Deniz on 17.10.24.
  6. //
  7. import Charts
  8. import Foundation
  9. import SwiftUI
  10. import WidgetKit
  11. private enum ForecastDisplayTypeKey {
  12. static let lines = "lines"
  13. static let cone = "cone"
  14. }
  15. struct LiveActivityChartView: View {
  16. @Environment(\.colorScheme) var colorScheme
  17. @Environment(\.isWatchOS) var isWatchOS
  18. var context: ActivityViewContext<LiveActivityAttributes>
  19. var additionalState: LiveActivityAttributes.ContentAdditionalState
  20. var body: some View {
  21. let state = context.state
  22. let isMgdL: Bool = state.unit == "mg/dL"
  23. let maxThreshhold: Decimal = isWatchOS ? 220 : 300
  24. // Determine scale, accounting for both glucose history and prediction values
  25. let chartMin = additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39
  26. let chartMax = additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold
  27. let forecastMin = additionalState.minForecast.min().map { Decimal($0) } ?? chartMin
  28. let forecastMax = min(additionalState.maxForecast.max().map { Decimal($0) } ?? chartMax, maxThreshhold)
  29. let minValue = min(min(chartMin, forecastMin), 39)
  30. let maxValue = max(max(chartMax, forecastMax), maxThreshhold)
  31. let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
  32. .asMmolL
  33. let yAxisRuleMarkMax = isMgdL ? state.highGlucose : state.highGlucose
  34. .asMmolL
  35. let target = isMgdL ? state.target : state.target.asMmolL
  36. let isOverrideActive = additionalState.isOverrideActive == true
  37. let isTempTargetActive = additionalState.isTempTargetActive == true
  38. let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
  39. let calendar = Calendar.current
  40. let now = Date()
  41. let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
  42. let endDate: Date = {
  43. let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
  44. guard hasForecast, let anchorDate = state.date else { return baseEnd }
  45. let forecastCount = max(
  46. additionalState.minForecast.count,
  47. additionalState.forecastLines.max(by: { $0.values.count < $1.values.count })?.values.count ?? 0
  48. )
  49. let predictionEnd = anchorDate.addingTimeInterval(TimeInterval(forecastCount * 300))
  50. return max(baseEnd, predictionEnd)
  51. }()
  52. // 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
  53. let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
  54. let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL
  55. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  56. let highColor = Color.getDynamicGlucoseColor(
  57. glucoseValue: yAxisRuleMarkMax,
  58. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  59. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  60. targetGlucose: target,
  61. glucoseColorScheme: context.state.glucoseColorScheme
  62. )
  63. let lowColor = Color.getDynamicGlucoseColor(
  64. glucoseValue: yAxisRuleMarkMin,
  65. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  66. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  67. targetGlucose: target,
  68. glucoseColorScheme: context.state.glucoseColorScheme
  69. )
  70. Chart {
  71. RuleMark(y: .value("High", yAxisRuleMarkMax))
  72. .foregroundStyle(highColor)
  73. .lineStyle(.init(lineWidth: 1, dash: [5]))
  74. RuleMark(y: .value("Low", yAxisRuleMarkMin))
  75. .foregroundStyle(lowColor)
  76. .lineStyle(.init(lineWidth: 1, dash: [5]))
  77. RuleMark(y: .value("Target", target))
  78. .foregroundStyle(.green.gradient)
  79. .lineStyle(.init(lineWidth: 1.5))
  80. if isOverrideActive {
  81. drawActiveOverrides()
  82. }
  83. if isTempTargetActive {
  84. drawActiveTempTarget()
  85. }
  86. if hasForecast, let anchorDate = state.date {
  87. if additionalState.forecastDisplayType == ForecastDisplayTypeKey.lines {
  88. drawForecastLines(anchorDate: anchorDate, isMgdL: isMgdL)
  89. } else {
  90. drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
  91. }
  92. }
  93. drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
  94. }
  95. .chartYAxis {
  96. AxisMarks(position: .trailing) { _ in
  97. AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3]))
  98. .foregroundStyle(Color.white.opacity(colorScheme == .light ? 1 : 0.5))
  99. AxisValueLabel().foregroundStyle(.primary).font(.footnote)
  100. }
  101. }
  102. .chartYScale(domain: state.unit == "mg/dL" ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
  103. .chartYAxis(.hidden)
  104. .chartPlotStyle { plotContent in
  105. plotContent
  106. .background(
  107. RoundedRectangle(cornerRadius: 12)
  108. .fill(colorScheme == .light ? Color.black.opacity(0.2) : .clear)
  109. )
  110. .clipShape(RoundedRectangle(cornerRadius: 12))
  111. }
  112. .chartXScale(domain: startDate ... endDate)
  113. .chartXAxis {
  114. AxisMarks(position: .automatic) { _ in
  115. AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3]))
  116. .foregroundStyle(Color.primary.opacity(colorScheme == .light ? 1 : 0.5))
  117. }
  118. }
  119. }
  120. private func drawActiveOverrides() -> some ChartContent {
  121. let start: Date = context.state.detailedViewState.overrideDate
  122. let duration = context.state.detailedViewState.overrideDuration
  123. let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
  124. let end: Date = duration == 0
  125. ? Date(timeIntervalSinceNow: 7200)
  126. : start.addingTimeInterval(durationAsTimeInterval)
  127. let target = context.state.detailedViewState.overrideTarget
  128. return RuleMark(
  129. xStart: .value("Start", start, unit: .second),
  130. xEnd: .value("End", end, unit: .second),
  131. y: .value("Value", target)
  132. )
  133. .foregroundStyle(Color.purple.opacity(0.6))
  134. .lineStyle(.init(lineWidth: 8))
  135. }
  136. private func drawActiveTempTarget() -> some ChartContent {
  137. let start: Date = context.state.detailedViewState.tempTargetDate
  138. let duration = context.state.detailedViewState.tempTargetDuration
  139. let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
  140. let end: Date = start.addingTimeInterval(durationAsTimeInterval)
  141. let target = context.state.detailedViewState.tempTargetTarget
  142. return RuleMark(
  143. xStart: .value("Start", start, unit: .second),
  144. xEnd: .value("End", end, unit: .second),
  145. y: .value("Value", target)
  146. )
  147. .foregroundStyle(Color("LoopGreen").opacity(0.6))
  148. .lineStyle(.init(lineWidth: 8))
  149. }
  150. private func timeForIndex(_ index: Int, anchorDate: Date) -> Date {
  151. anchorDate.addingTimeInterval(TimeInterval(index * 300))
  152. }
  153. private func drawForecastCone(anchorDate: Date, isMgdL: Bool, maxValue: Decimal) -> some ChartContent {
  154. let minForecast = additionalState.minForecast
  155. let maxForecast = additionalState.maxForecast
  156. let cappedMax = isMgdL ? maxValue : maxValue.asMmolL
  157. let count = min(minForecast.count, maxForecast.count)
  158. // Pre-compute cone data to avoid conditionals inside the ForEach closure
  159. let coneData: [(date: Date, yMin: Decimal, yMax: Decimal)] = (0 ..< count).map { index in
  160. let xValue = timeForIndex(index, anchorDate: anchorDate)
  161. let delta = minForecast[index] - maxForecast[index]
  162. let yMin: Decimal
  163. let yMax: Decimal
  164. if delta == 0 {
  165. let base = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
  166. yMin = base - 1
  167. yMax = base + 1
  168. } else {
  169. yMin = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
  170. yMax = isMgdL ? Decimal(maxForecast[index]) : Decimal(maxForecast[index]).asMmolL
  171. }
  172. return (date: xValue, yMin: min(yMin, cappedMax), yMax: min(yMax, cappedMax))
  173. }
  174. return ForEach(coneData.indices, id: \.self) { i in
  175. AreaMark(
  176. x: .value("Time", coneData[i].date),
  177. yStart: .value("Min", coneData[i].yMin),
  178. yEnd: .value("Max", coneData[i].yMax)
  179. )
  180. .foregroundStyle(Color.blue.opacity(0.5))
  181. .interpolationMethod(.linear)
  182. }
  183. }
  184. private func drawForecastLines(anchorDate: Date, isMgdL: Bool) -> some ChartContent {
  185. let colorMap: [String: Color] = [
  186. "iob": Color(red: 0.118, green: 0.588, blue: 0.988),
  187. "cob": Color.orange,
  188. "uam": Color(red: 0.820, green: 0.169, blue: 0.969),
  189. "zt": Color(red: 0.443, green: 0.380, blue: 0.937)
  190. ]
  191. let points: [(series: String, date: Date, value: Decimal)] = additionalState.forecastLines.flatMap { line in
  192. line.values.enumerated().map { index, value in
  193. let displayValue = isMgdL ? Decimal(value) : Decimal(value).asMmolL
  194. return (series: line.type, date: timeForIndex(index, anchorDate: anchorDate), value: displayValue)
  195. }
  196. }
  197. return ForEach(0 ..< points.count, id: \.self) { i in
  198. let point = points[i]
  199. LineMark(
  200. x: .value("Time", point.date),
  201. y: .value("Value", point.value),
  202. series: .value("Type", point.series)
  203. )
  204. .foregroundStyle(colorMap[point.series] ?? Color.gray)
  205. .lineStyle(.init(lineWidth: 1.5))
  206. .interpolationMethod(.linear)
  207. }
  208. }
  209. private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
  210. // 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
  211. let hardCodedLow = Decimal(55)
  212. let hardCodedHigh = Decimal(220)
  213. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  214. let isMgdL = context.state.unit == "mg/dL"
  215. let threeHours = TimeInterval(10800)
  216. let chartData = isWatchOS ? additionalState.chart
  217. .filter { abs($0.date.timeIntervalSinceNow) < threeHours } : additionalState
  218. .chart
  219. return ForEach(chartData, id: \.self) { item in
  220. let displayValue = isMgdL ? item.value : item.value.asMmolL
  221. let pointMarkColor = Color.getDynamicGlucoseColor(
  222. glucoseValue: item.value,
  223. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : context.state.highGlucose,
  224. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : context.state.lowGlucose,
  225. targetGlucose: context.state.target,
  226. glucoseColorScheme: context.state.glucoseColorScheme
  227. )
  228. let pointMark = PointMark(
  229. x: .value("Time", item.date),
  230. y: .value("Value", displayValue)
  231. )
  232. .symbolSize(16)
  233. .shadow(color: Color.black.opacity(0.25), radius: 2, x: 0, y: 0)
  234. pointMark.foregroundStyle(pointMarkColor)
  235. }
  236. }
  237. }