LiveActivity.swift 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  1. import ActivityKit
  2. import Charts
  3. import SwiftUI
  4. import WidgetKit
  5. private enum Size {
  6. case minimal
  7. case compact
  8. case expanded
  9. }
  10. enum GlucoseUnits: String, Equatable {
  11. case mgdL = "mg/dL"
  12. case mmolL = "mmol/L"
  13. static let exchangeRate: Decimal = 0.0555
  14. }
  15. enum GlucoseColorScheme: String, Equatable {
  16. case staticColor
  17. case dynamicColor
  18. }
  19. func rounded(_ value: Decimal, scale: Int, roundingMode: NSDecimalNumber.RoundingMode) -> Decimal {
  20. var result = Decimal()
  21. var toRound = value
  22. NSDecimalRound(&result, &toRound, scale, roundingMode)
  23. return result
  24. }
  25. extension Int {
  26. var asMmolL: Decimal {
  27. rounded(Decimal(self) * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
  28. }
  29. var formattedAsMmolL: String {
  30. NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
  31. }
  32. }
  33. extension Decimal {
  34. var asMmolL: Decimal {
  35. rounded(self * GlucoseUnits.exchangeRate, scale: 1, roundingMode: .plain)
  36. }
  37. var asMgdL: Decimal {
  38. rounded(self / GlucoseUnits.exchangeRate, scale: 0, roundingMode: .plain)
  39. }
  40. var formattedAsMmolL: String {
  41. NumberFormatter.glucoseFormatter.string(from: asMmolL as NSDecimalNumber) ?? "\(asMmolL)"
  42. }
  43. }
  44. extension NumberFormatter {
  45. static let glucoseFormatter: NumberFormatter = {
  46. let formatter = NumberFormatter()
  47. formatter.locale = Locale.current
  48. formatter.numberStyle = .decimal
  49. formatter.minimumFractionDigits = 1
  50. formatter.maximumFractionDigits = 1
  51. return formatter
  52. }()
  53. }
  54. extension Color {
  55. // Helper function to decide how to pick the glucose color
  56. static func getDynamicGlucoseColor(
  57. glucoseValue: Decimal,
  58. highGlucoseColorValue: Decimal,
  59. lowGlucoseColorValue: Decimal,
  60. targetGlucose: Decimal,
  61. glucoseColorScheme: String
  62. ) -> Color {
  63. // Only use calculateHueBasedGlucoseColor if the setting is enabled in preferences
  64. if glucoseColorScheme == "dynamicColor" {
  65. return calculateHueBasedGlucoseColor(
  66. glucoseValue: glucoseValue,
  67. highGlucose: highGlucoseColorValue,
  68. lowGlucose: lowGlucoseColorValue,
  69. targetGlucose: targetGlucose
  70. )
  71. }
  72. // Otheriwse, use static (orange = high, red = low, green = range)
  73. else {
  74. if glucoseValue >= highGlucoseColorValue {
  75. return Color.orange
  76. } else if glucoseValue <= lowGlucoseColorValue {
  77. return Color.red
  78. } else {
  79. return Color.green
  80. }
  81. }
  82. }
  83. // Dynamic color - Define the hue values for the key points
  84. // We'll shift color gradually one glucose point at a time
  85. // We'll shift through the rainbow colors of ROY-G-BIV from low to high
  86. // Start at red for lowGlucose, green for targetGlucose, and violet for highGlucose
  87. private static func calculateHueBasedGlucoseColor(
  88. glucoseValue: Decimal,
  89. highGlucose: Decimal,
  90. lowGlucose: Decimal,
  91. targetGlucose: Decimal
  92. ) -> Color {
  93. let redHue: CGFloat = 0.0 / 360.0 // 0 degrees
  94. let greenHue: CGFloat = 120.0 / 360.0 // 120 degrees
  95. let purpleHue: CGFloat = 270.0 / 360.0 // 270 degrees
  96. // Calculate the hue based on the bgLevel
  97. var hue: CGFloat
  98. if glucoseValue <= lowGlucose {
  99. hue = redHue
  100. } else if glucoseValue >= highGlucose {
  101. hue = purpleHue
  102. } else if glucoseValue <= targetGlucose {
  103. // Interpolate between red and green
  104. let ratio = CGFloat(truncating: (glucoseValue - lowGlucose) / (targetGlucose - lowGlucose) as NSNumber)
  105. hue = redHue + ratio * (greenHue - redHue)
  106. } else {
  107. // Interpolate between green and purple
  108. let ratio = CGFloat(truncating: (glucoseValue - targetGlucose) / (highGlucose - targetGlucose) as NSNumber)
  109. hue = greenHue + ratio * (purpleHue - greenHue)
  110. }
  111. // Return the color with full saturation and brightness
  112. let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
  113. return color
  114. }
  115. }
  116. struct LiveActivity: Widget {
  117. var body: some WidgetConfiguration {
  118. ActivityConfiguration(for: LiveActivityAttributes.self) { context in
  119. LiveActivityView(context: context)
  120. } dynamicIsland: { context in
  121. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  122. var glucoseColor: Color {
  123. let state = context.state
  124. let detailedState = state.detailedViewState
  125. let isMgdL = detailedState?.unit == "mg/dL"
  126. // 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
  127. let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
  128. let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL
  129. return Color.getDynamicGlucoseColor(
  130. glucoseValue: Decimal(string: state.bg) ?? 100,
  131. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : state.highGlucose,
  132. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : state.lowGlucose,
  133. targetGlucose: isMgdL ? state.target : state.target.asMmolL,
  134. glucoseColorScheme: state.glucoseColorScheme
  135. )
  136. }
  137. return DynamicIsland {
  138. DynamicIslandExpandedRegion(.leading) {
  139. LiveActivityExpandedLeadingView(context: context, glucoseColor: glucoseColor)
  140. }
  141. DynamicIslandExpandedRegion(.trailing) {
  142. LiveActivityExpandedTrailingView(
  143. context: context,
  144. glucoseColor: hasStaticColorScheme ? .primary : glucoseColor
  145. )
  146. }
  147. DynamicIslandExpandedRegion(.bottom) {
  148. LiveActivityExpandedBottomView(context: context)
  149. }
  150. DynamicIslandExpandedRegion(.center) {
  151. LiveActivityExpandedCenterView(context: context)
  152. }
  153. } compactLeading: {
  154. LiveActivityCompactLeadingView(context: context, glucoseColor: glucoseColor)
  155. } compactTrailing: {
  156. LiveActivityCompactTrailingView(context: context, glucoseColor: hasStaticColorScheme ? .primary : glucoseColor)
  157. } minimal: {
  158. LiveActivityMinimalView(context: context, glucoseColor: glucoseColor)
  159. }
  160. .widgetURL(URL(string: "Trio://"))
  161. .keylineTint(glucoseColor)
  162. .contentMargins(.horizontal, 0, for: .minimal)
  163. .contentMargins(.trailing, 0, for: .compactLeading)
  164. .contentMargins(.leading, 0, for: .compactTrailing)
  165. }
  166. }
  167. }
  168. struct LiveActivityView: View {
  169. @Environment(\.colorScheme) var colorScheme
  170. var context: ActivityViewContext<LiveActivityAttributes>
  171. private var hasStaticColorScheme: Bool {
  172. context.state.glucoseColorScheme == "staticColor"
  173. }
  174. private var glucoseColor: Color {
  175. let state = context.state
  176. let detailedState = state.detailedViewState
  177. let isMgdL = detailedState?.unit == "mg/dL"
  178. // 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
  179. let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
  180. let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL
  181. return Color.getDynamicGlucoseColor(
  182. glucoseValue: Decimal(string: state.bg) ?? 100,
  183. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : state.highGlucose,
  184. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : state.lowGlucose,
  185. targetGlucose: isMgdL ? state.target : state.target.asMmolL,
  186. glucoseColorScheme: state.glucoseColorScheme
  187. )
  188. }
  189. var body: some View {
  190. if let detailedViewState = context.state.detailedViewState {
  191. VStack {
  192. LiveActivityChartView(context: context, additionalState: detailedViewState)
  193. .frame(maxWidth: UIScreen.main.bounds.width * 0.9)
  194. .frame(height: 80)
  195. .overlay(alignment: .topTrailing) {
  196. if detailedViewState.isOverrideActive {
  197. HStack {
  198. Text("\(detailedViewState.overrideName)")
  199. .font(.footnote)
  200. .fontWeight(.bold)
  201. .foregroundStyle(.white)
  202. }
  203. .padding(6)
  204. .background {
  205. RoundedRectangle(cornerRadius: 10)
  206. .fill(Color.purple.opacity(colorScheme == .dark ? 0.6 : 0.8))
  207. }
  208. }
  209. }
  210. HStack {
  211. if detailedViewState.widgetItems.contains(where: { $0 != .empty }) {
  212. ForEach(Array(detailedViewState.widgetItems.enumerated()), id: \.element) { index, widgetItem in
  213. switch widgetItem {
  214. case .currentGlucose:
  215. VStack {
  216. LiveActivityBGLabelView(context: context, additionalState: detailedViewState)
  217. HStack {
  218. LiveActivityGlucoseDeltaLabelView(
  219. context: context,
  220. glucoseColor: .primary,
  221. isDetailed: true
  222. )
  223. if !context.isStale, let direction = context.state.direction {
  224. Text(direction).font(.headline)
  225. }
  226. }
  227. }
  228. case .iob:
  229. LiveActivityIOBLabelView(context: context, additionalState: detailedViewState)
  230. case .cob:
  231. LiveActivityCOBLabelView(context: context, additionalState: detailedViewState)
  232. case .updatedLabel:
  233. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: true)
  234. case .empty:
  235. Text("").frame(width: 50, height: 50)
  236. }
  237. /// Check if the next item is also non-empty to determine if a divider should be shown
  238. if index < detailedViewState.widgetItems.count - 1 {
  239. let currentItem = detailedViewState.widgetItems[index]
  240. let nextItem = detailedViewState.widgetItems[index + 1]
  241. if currentItem != .empty, nextItem != .empty {
  242. Divider()
  243. .foregroundStyle(.primary)
  244. .fontWeight(.bold)
  245. .frame(width: 10)
  246. }
  247. }
  248. }
  249. }
  250. }
  251. }
  252. .privacySensitive()
  253. .padding(.all, 14)
  254. .foregroundStyle(Color.primary)
  255. .activityBackgroundTint(colorScheme == .light ? Color.white.opacity(0.43) : Color.black.opacity(0.43))
  256. } else {
  257. Group {
  258. if context.state.isInitialState {
  259. Text("Live Activity Expired. Open Trio to Refresh").minimumScaleFactor(0.01)
  260. } else {
  261. HStack(spacing: 3) {
  262. LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title)
  263. Spacer()
  264. VStack(alignment: .trailing, spacing: 5) {
  265. LiveActivityGlucoseDeltaLabelView(
  266. context: context,
  267. glucoseColor: hasStaticColorScheme ? .primary : glucoseColor,
  268. isDetailed: false
  269. ).font(.title3)
  270. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: false).font(.caption)
  271. .foregroundStyle(.primary.opacity(0.7))
  272. }
  273. }
  274. }
  275. }
  276. .privacySensitive()
  277. .padding(.all, 15)
  278. .foregroundStyle(Color.primary)
  279. .activityBackgroundTint(colorScheme == .light ? Color.white.opacity(0.43) : Color.black.opacity(0.43))
  280. }
  281. }
  282. }
  283. // Separate the smaller sections into reusable views
  284. struct LiveActivityBGAndTrendView: View {
  285. var context: ActivityViewContext<LiveActivityAttributes>
  286. fileprivate var size: Size
  287. var glucoseColor: Color
  288. var body: some View {
  289. let (view, _) = bgAndTrend(context: context, size: size, glucoseColor: glucoseColor)
  290. return view
  291. }
  292. }
  293. struct LiveActivityBGLabelView: View {
  294. var context: ActivityViewContext<LiveActivityAttributes>
  295. var additionalState: LiveActivityAttributes.ContentAdditionalState
  296. var body: some View {
  297. Text(context.state.bg)
  298. .fontWeight(.bold)
  299. .font(.title3)
  300. .foregroundStyle(context.isStale ? .secondary : .primary)
  301. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  302. }
  303. }
  304. struct LiveActivityGlucoseDeltaLabelView: View {
  305. var context: ActivityViewContext<LiveActivityAttributes>
  306. var glucoseColor: Color
  307. var isDetailed: Bool = false
  308. var body: some View {
  309. if !context.state.change.isEmpty {
  310. Text(context.state.change)
  311. .foregroundStyle(context.state.glucoseColorScheme == "staticColor" ? .primary : glucoseColor)
  312. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  313. } else {
  314. Text("--")
  315. }
  316. }
  317. }
  318. struct LiveActivityIOBLabelView: View {
  319. var context: ActivityViewContext<LiveActivityAttributes>
  320. var additionalState: LiveActivityAttributes.ContentAdditionalState
  321. private var bolusFormatter: NumberFormatter {
  322. let formatter = NumberFormatter()
  323. formatter.numberStyle = .decimal
  324. formatter.maximumFractionDigits = 1
  325. formatter.decimalSeparator = "."
  326. return formatter
  327. }
  328. var body: some View {
  329. VStack(spacing: 2) {
  330. HStack {
  331. Text(
  332. bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--"
  333. )
  334. .fontWeight(.bold)
  335. .font(.title3)
  336. .foregroundStyle(context.isStale ? .secondary : .primary)
  337. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  338. Text("U")
  339. .font(.headline).fontWeight(.bold)
  340. .foregroundStyle(context.isStale ? .secondary : .primary)
  341. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  342. }
  343. Text("IOB").font(.subheadline).foregroundStyle(.primary)
  344. }
  345. }
  346. }
  347. struct LiveActivityCOBLabelView: View {
  348. var context: ActivityViewContext<LiveActivityAttributes>
  349. var additionalState: LiveActivityAttributes.ContentAdditionalState
  350. var body: some View {
  351. VStack(spacing: 2) {
  352. HStack {
  353. Text(
  354. "\(additionalState.cob)"
  355. ).fontWeight(.bold)
  356. .font(.title3)
  357. .foregroundStyle(context.isStale ? .secondary : .primary)
  358. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  359. Text("g")
  360. .font(.headline).fontWeight(.bold)
  361. .foregroundStyle(context.isStale ? .secondary : .primary)
  362. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  363. }
  364. Text("COB").font(.subheadline).foregroundStyle(.primary)
  365. }
  366. }
  367. }
  368. struct LiveActivityUpdatedLabelView: View {
  369. var context: ActivityViewContext<LiveActivityAttributes>
  370. var isDetailedLayout: Bool
  371. private var dateFormatter: DateFormatter {
  372. let formatter = DateFormatter()
  373. formatter.dateStyle = .none
  374. formatter.timeStyle = .short
  375. return formatter
  376. }
  377. var body: some View {
  378. let dateText = Text("\(dateFormatter.string(from: context.state.date))")
  379. if isDetailedLayout {
  380. VStack {
  381. dateText
  382. .font(.title3)
  383. .bold()
  384. .foregroundStyle(context.isStale ? .red.opacity(0.6) : .primary)
  385. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  386. Text("Updated").font(.subheadline).foregroundStyle(.primary)
  387. }
  388. } else {
  389. HStack {
  390. Text("Updated:").font(.subheadline).foregroundStyle(.secondary)
  391. dateText
  392. .font(.subheadline)
  393. .bold()
  394. .foregroundStyle(context.isStale ? .red.opacity(0.6) : .secondary)
  395. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  396. }
  397. }
  398. }
  399. }
  400. struct LiveActivityChartView: View {
  401. @Environment(\.colorScheme) var colorScheme
  402. var context: ActivityViewContext<LiveActivityAttributes>
  403. var additionalState: LiveActivityAttributes.ContentAdditionalState
  404. var body: some View {
  405. let state = context.state
  406. let isMgdL: Bool = additionalState.unit == "mg/dL"
  407. // Determine scale
  408. let minValue = min(additionalState.chart.min() ?? 39, 39) as Decimal
  409. let maxValue = max(additionalState.chart.max() ?? 300, 300) as Decimal
  410. let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
  411. .asMmolL
  412. let yAxisRuleMarkMax = isMgdL ? state.highGlucose : state.highGlucose
  413. .asMmolL
  414. let target = isMgdL ? state.target : state.target.asMmolL
  415. let isOverrideActive = additionalState.isOverrideActive == true
  416. let calendar = Calendar.current
  417. let now = Date()
  418. let startDate = calendar.date(byAdding: .hour, value: -6, to: now) ?? now
  419. let endDate = isOverrideActive ? (calendar.date(byAdding: .hour, value: 2, to: now) ?? now) : now
  420. // 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
  421. let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
  422. let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL
  423. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  424. let highColor = Color.getDynamicGlucoseColor(
  425. glucoseValue: yAxisRuleMarkMax,
  426. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  427. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  428. targetGlucose: target,
  429. glucoseColorScheme: context.state.glucoseColorScheme
  430. )
  431. let lowColor = Color.getDynamicGlucoseColor(
  432. glucoseValue: yAxisRuleMarkMin,
  433. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  434. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  435. targetGlucose: target,
  436. glucoseColorScheme: context.state.glucoseColorScheme
  437. )
  438. Chart {
  439. RuleMark(y: .value("High", yAxisRuleMarkMax))
  440. .foregroundStyle(highColor)
  441. .lineStyle(.init(lineWidth: 1, dash: [5]))
  442. RuleMark(y: .value("Low", yAxisRuleMarkMin))
  443. .foregroundStyle(lowColor)
  444. .lineStyle(.init(lineWidth: 1, dash: [5]))
  445. RuleMark(y: .value("Target", target))
  446. .foregroundStyle(.green.gradient)
  447. .lineStyle(.init(lineWidth: 1.5))
  448. if isOverrideActive {
  449. drawActiveOverrides()
  450. }
  451. drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
  452. }
  453. .chartYAxis {
  454. AxisMarks(position: .trailing) { _ in
  455. AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3]))
  456. .foregroundStyle(Color.white.opacity(colorScheme == .light ? 1 : 0.5))
  457. AxisValueLabel().foregroundStyle(.primary).font(.footnote)
  458. }
  459. }
  460. .chartYScale(domain: additionalState.unit == "mg/dL" ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
  461. .chartYAxis(.hidden)
  462. .chartPlotStyle { plotContent in
  463. plotContent
  464. .background(
  465. RoundedRectangle(cornerRadius: 12)
  466. .fill(colorScheme == .light ? Color.black.opacity(0.275) : .clear)
  467. )
  468. .clipShape(RoundedRectangle(cornerRadius: 12))
  469. }
  470. .chartXScale(domain: startDate ... endDate)
  471. .chartXAxis {
  472. AxisMarks(position: .automatic) { _ in
  473. AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3]))
  474. .foregroundStyle(Color.white.opacity(colorScheme == .light ? 1 : 0.5))
  475. }
  476. }
  477. }
  478. private func drawActiveOverrides() -> some ChartContent {
  479. let start: Date = context.state.detailedViewState?.overrideDate ?? .distantPast
  480. let duration = context.state.detailedViewState?.overrideDuration ?? 0
  481. let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
  482. let end: Date = start.addingTimeInterval(durationAsTimeInterval)
  483. let target = context.state.detailedViewState?.overrideTarget ?? 0
  484. return RuleMark(
  485. xStart: .value("Start", start, unit: .second),
  486. xEnd: .value("End", end, unit: .second),
  487. y: .value("Value", target)
  488. )
  489. .foregroundStyle(Color.purple.opacity(0.6))
  490. .lineStyle(.init(lineWidth: 8))
  491. }
  492. private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
  493. ForEach(additionalState.chart.indices, id: \.self) { index in
  494. let isMgdL = additionalState.unit == "mg/dL"
  495. let currentValue = additionalState.chart[index]
  496. let displayValue = isMgdL ? currentValue : currentValue.asMmolL
  497. let chartDate = additionalState.chartDate[index] ?? Date()
  498. // 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
  499. let hardCodedLow = Decimal(55)
  500. let hardCodedHigh = Decimal(220)
  501. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  502. let pointMarkColor = Color.getDynamicGlucoseColor(
  503. glucoseValue: currentValue,
  504. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : context.state.highGlucose,
  505. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : context.state.lowGlucose,
  506. targetGlucose: context.state.target,
  507. glucoseColorScheme: context.state.glucoseColorScheme
  508. )
  509. let pointMark = PointMark(
  510. x: .value("Time", chartDate),
  511. y: .value("Value", displayValue)
  512. ).symbolSize(16)
  513. pointMark.foregroundStyle(pointMarkColor)
  514. }
  515. }
  516. }
  517. // Expanded, minimal, compact view components
  518. struct LiveActivityExpandedLeadingView: View {
  519. var context: ActivityViewContext<LiveActivityAttributes>
  520. var glucoseColor: Color
  521. var body: some View {
  522. LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title2)
  523. .padding(.leading, 5)
  524. }
  525. }
  526. struct LiveActivityExpandedTrailingView: View {
  527. var context: ActivityViewContext<LiveActivityAttributes>
  528. var glucoseColor: Color
  529. var body: some View {
  530. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor, isDetailed: false).font(.title2)
  531. .padding(.trailing, 5)
  532. }
  533. }
  534. struct LiveActivityExpandedBottomView: View {
  535. var context: ActivityViewContext<LiveActivityAttributes>
  536. var body: some View {
  537. if context.state.isInitialState {
  538. Text("Live Activity Expired. Open Trio to Refresh").minimumScaleFactor(0.01)
  539. } else if let detailedViewState = context.state.detailedViewState {
  540. LiveActivityChartView(context: context, additionalState: detailedViewState)
  541. }
  542. }
  543. }
  544. struct LiveActivityExpandedCenterView: View {
  545. var context: ActivityViewContext<LiveActivityAttributes>
  546. var body: some View {
  547. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: false).font(.caption).foregroundStyle(Color.secondary)
  548. }
  549. }
  550. struct LiveActivityCompactLeadingView: View {
  551. var context: ActivityViewContext<LiveActivityAttributes>
  552. var glucoseColor: Color
  553. var body: some View {
  554. LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor).padding(.leading, 4)
  555. }
  556. }
  557. struct LiveActivityCompactTrailingView: View {
  558. var context: ActivityViewContext<LiveActivityAttributes>
  559. var glucoseColor: Color
  560. var body: some View {
  561. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor, isDetailed: false).padding(.trailing, 4)
  562. }
  563. }
  564. struct LiveActivityMinimalView: View {
  565. var context: ActivityViewContext<LiveActivityAttributes>
  566. var glucoseColor: Color
  567. var body: some View {
  568. let (label, characterCount) = bgAndTrend(context: context, size: .minimal, glucoseColor: glucoseColor)
  569. let adjustedLabel = label.padding(.leading, 5).padding(.trailing, 2)
  570. if characterCount < 4 {
  571. adjustedLabel.fontWidth(.condensed)
  572. } else if characterCount < 5 {
  573. adjustedLabel.fontWidth(.compressed)
  574. } else {
  575. adjustedLabel.fontWidth(.compressed)
  576. }
  577. }
  578. }
  579. private func bgAndTrend(
  580. context: ActivityViewContext<LiveActivityAttributes>,
  581. size: Size,
  582. glucoseColor: Color
  583. ) -> (some View, Int) {
  584. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  585. var characters = 0
  586. let bgText = context.state.bg
  587. characters += bgText.count
  588. // narrow mode is for the minimal dynamic island view
  589. // there is not enough space to show all three arrow there
  590. // and everything has to be squeezed together to some degree
  591. // only display the first arrow character and make it red in case there were more characters
  592. var directionText: String?
  593. if let direction = context.state.direction {
  594. if size == .compact || size == .minimal {
  595. directionText = String(direction[direction.startIndex ... direction.startIndex])
  596. } else {
  597. directionText = direction
  598. }
  599. characters += directionText!.count
  600. }
  601. let spacing: CGFloat
  602. switch size {
  603. case .minimal: spacing = -1
  604. case .compact: spacing = 0
  605. case .expanded: spacing = 3
  606. }
  607. let stack = HStack(spacing: spacing) {
  608. Text(bgText)
  609. .foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  610. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  611. if let direction = directionText {
  612. let text = Text(direction)
  613. switch size {
  614. case .minimal:
  615. let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
  616. scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  617. case .compact:
  618. text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
  619. case .expanded:
  620. text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
  621. }
  622. }
  623. }.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  624. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  625. return (stack, characters)
  626. }
  627. // Mock structure to replace GlucoseData
  628. struct MockGlucoseData {
  629. var glucose: Int
  630. var date: Date
  631. var direction: String? // You can refine this based on your expected data
  632. }
  633. private extension LiveActivityAttributes {
  634. static var preview: LiveActivityAttributes {
  635. LiveActivityAttributes(startDate: Date())
  636. }
  637. }
  638. private extension LiveActivityAttributes.ContentState {
  639. static var chartData: [MockGlucoseData] = [
  640. MockGlucoseData(glucose: 120, date: Date().addingTimeInterval(-600), direction: "flat"),
  641. MockGlucoseData(glucose: 125, date: Date().addingTimeInterval(-300), direction: "flat"),
  642. MockGlucoseData(glucose: 130, date: Date(), direction: "flat")
  643. ]
  644. static var detailedViewState = LiveActivityAttributes.ContentAdditionalState(
  645. chart: chartData.map { Decimal($0.glucose) },
  646. chartDate: chartData.map(\.date),
  647. rotationDegrees: 0,
  648. cob: 20,
  649. iob: 1.5,
  650. unit: GlucoseUnits.mgdL.rawValue,
  651. isOverrideActive: false,
  652. overrideName: "Exercise",
  653. overrideDate: Date().addingTimeInterval(-3600),
  654. overrideDuration: 120,
  655. overrideTarget: 150,
  656. widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems
  657. )
  658. // 0 is the widest digit. Use this to get an upper bound on text width.
  659. // Use mmol/l notation with decimal point as well for the same reason, it uses up to 4 characters, while mg/dl uses up to 3
  660. static var testWide: LiveActivityAttributes.ContentState {
  661. LiveActivityAttributes.ContentState(
  662. bg: "00.0",
  663. direction: "→",
  664. change: "+0.0",
  665. date: Date(),
  666. highGlucose: 180,
  667. lowGlucose: 70,
  668. target: 100,
  669. glucoseColorScheme: "staticColor",
  670. detailedViewState: nil,
  671. isInitialState: false
  672. )
  673. }
  674. static var testVeryWide: LiveActivityAttributes.ContentState {
  675. LiveActivityAttributes.ContentState(
  676. bg: "00.0",
  677. direction: "↑↑",
  678. change: "+0.0",
  679. date: Date(),
  680. highGlucose: 180,
  681. lowGlucose: 70,
  682. target: 100,
  683. glucoseColorScheme: "staticColor",
  684. detailedViewState: nil,
  685. isInitialState: false
  686. )
  687. }
  688. static var testSuperWide: LiveActivityAttributes.ContentState {
  689. LiveActivityAttributes.ContentState(
  690. bg: "00.0",
  691. direction: "↑↑↑",
  692. change: "+0.0",
  693. date: Date(),
  694. highGlucose: 180,
  695. lowGlucose: 70,
  696. target: 100,
  697. glucoseColorScheme: "staticColor",
  698. detailedViewState: nil,
  699. isInitialState: false
  700. )
  701. }
  702. // 2 characters for BG, 1 character for change is the minimum that will be shown
  703. static var testNarrow: LiveActivityAttributes.ContentState {
  704. LiveActivityAttributes.ContentState(
  705. bg: "00",
  706. direction: "↑",
  707. change: "+0",
  708. date: Date(),
  709. highGlucose: 180,
  710. lowGlucose: 70,
  711. target: 100,
  712. glucoseColorScheme: "staticColor",
  713. detailedViewState: nil,
  714. isInitialState: false
  715. )
  716. }
  717. static var testMedium: LiveActivityAttributes.ContentState {
  718. LiveActivityAttributes.ContentState(
  719. bg: "000",
  720. direction: "↗︎",
  721. change: "+00",
  722. date: Date(),
  723. highGlucose: 180,
  724. lowGlucose: 70,
  725. target: 100,
  726. glucoseColorScheme: "staticColor",
  727. detailedViewState: nil,
  728. isInitialState: false
  729. )
  730. }
  731. static var testExpired: LiveActivityAttributes.ContentState {
  732. LiveActivityAttributes.ContentState(
  733. bg: "--",
  734. direction: nil,
  735. change: "--",
  736. date: Date().addingTimeInterval(-60 * 60),
  737. highGlucose: 180,
  738. lowGlucose: 70,
  739. target: 100,
  740. glucoseColorScheme: "staticColor",
  741. detailedViewState: nil,
  742. isInitialState: false
  743. )
  744. }
  745. static var testWideDetailed: LiveActivityAttributes.ContentState {
  746. LiveActivityAttributes.ContentState(
  747. bg: "00.0",
  748. direction: "→",
  749. change: "+0.0",
  750. date: Date(),
  751. highGlucose: 180,
  752. lowGlucose: 70,
  753. target: 100,
  754. glucoseColorScheme: "staticColor",
  755. detailedViewState: detailedViewState,
  756. isInitialState: false
  757. )
  758. }
  759. static var testVeryWideDetailed: LiveActivityAttributes.ContentState {
  760. LiveActivityAttributes.ContentState(
  761. bg: "00.0",
  762. direction: "↑↑",
  763. change: "+0.0",
  764. date: Date(),
  765. highGlucose: 180,
  766. lowGlucose: 70,
  767. target: 100,
  768. glucoseColorScheme: "staticColor",
  769. detailedViewState: detailedViewState,
  770. isInitialState: false
  771. )
  772. }
  773. static var testSuperWideDetailed: LiveActivityAttributes.ContentState {
  774. LiveActivityAttributes.ContentState(
  775. bg: "00.0",
  776. direction: "↑↑↑",
  777. change: "+0.0",
  778. date: Date(),
  779. highGlucose: 180,
  780. lowGlucose: 70,
  781. target: 100,
  782. glucoseColorScheme: "staticColor",
  783. detailedViewState: detailedViewState,
  784. isInitialState: false
  785. )
  786. }
  787. // 2 characters for BG, 1 character for change is the minimum that will be shown
  788. static var testNarrowDetailed: LiveActivityAttributes.ContentState {
  789. LiveActivityAttributes.ContentState(
  790. bg: "00",
  791. direction: "↑",
  792. change: "+0",
  793. date: Date(),
  794. highGlucose: 180,
  795. lowGlucose: 70,
  796. target: 100,
  797. glucoseColorScheme: "staticColor",
  798. detailedViewState: detailedViewState,
  799. isInitialState: false
  800. )
  801. }
  802. static var testMediumDetailed: LiveActivityAttributes.ContentState {
  803. LiveActivityAttributes.ContentState(
  804. bg: "000",
  805. direction: "↗︎",
  806. change: "+00",
  807. date: Date(),
  808. highGlucose: 180,
  809. lowGlucose: 70,
  810. target: 100,
  811. glucoseColorScheme: "staticColor",
  812. detailedViewState: detailedViewState,
  813. isInitialState: false
  814. )
  815. }
  816. static var testExpiredDetailed: LiveActivityAttributes.ContentState {
  817. LiveActivityAttributes.ContentState(
  818. bg: "--",
  819. direction: nil,
  820. change: "--",
  821. date: Date().addingTimeInterval(-60 * 60),
  822. highGlucose: 180,
  823. lowGlucose: 70,
  824. target: 100,
  825. glucoseColorScheme: "staticColor",
  826. detailedViewState: detailedViewState,
  827. isInitialState: false
  828. )
  829. }
  830. }
  831. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  832. #Preview("Simple", as: .content, using: LiveActivityAttributes.preview) {
  833. LiveActivity()
  834. } contentStates: {
  835. LiveActivityAttributes.ContentState.testSuperWide
  836. LiveActivityAttributes.ContentState.testVeryWide
  837. LiveActivityAttributes.ContentState.testWide
  838. LiveActivityAttributes.ContentState.testMedium
  839. LiveActivityAttributes.ContentState.testNarrow
  840. LiveActivityAttributes.ContentState.testExpired
  841. }
  842. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  843. #Preview("Detailed", as: .content, using: LiveActivityAttributes.preview) {
  844. LiveActivity()
  845. } contentStates: {
  846. LiveActivityAttributes.ContentState.testSuperWideDetailed
  847. LiveActivityAttributes.ContentState.testVeryWideDetailed
  848. LiveActivityAttributes.ContentState.testWideDetailed
  849. LiveActivityAttributes.ContentState.testMediumDetailed
  850. LiveActivityAttributes.ContentState.testNarrowDetailed
  851. LiveActivityAttributes.ContentState.testExpiredDetailed
  852. }