LiveActivityChartView.swift 10 KB

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