LiveActivity.swift 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  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(Color.purple)
  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. HStack {
  212. HStack {
  213. ForEach(Array(detailedViewState.itemOrder.enumerated()), id: \.element) { index, widgetItem in
  214. switch widgetItem {
  215. case .currentGlucose:
  216. if detailedViewState.showCurrentGlucose {
  217. VStack {
  218. LiveActivityBGLabelView(context: context, additionalState: detailedViewState)
  219. HStack {
  220. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: .primary)
  221. if !context.isStale, let direction = context.state.direction {
  222. Text(direction).font(.headline)
  223. }
  224. }
  225. }
  226. }
  227. case .iob:
  228. if detailedViewState.showIOB {
  229. LiveActivityIOBLabelView(context: context, additionalState: detailedViewState)
  230. }
  231. case .cob:
  232. if detailedViewState.showCOB {
  233. LiveActivityCOBLabelView(context: context, additionalState: detailedViewState)
  234. }
  235. case .updatedLabel:
  236. if detailedViewState.showUpdatedLabel {
  237. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: true)
  238. }
  239. case .empty:
  240. Text("").frame(width: 50, height: 50)
  241. }
  242. if index < detailedViewState.itemOrder.count - 1 {
  243. Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
  244. }
  245. }
  246. }
  247. }
  248. }
  249. }
  250. .privacySensitive()
  251. .padding(.all, 14)
  252. .foregroundStyle(Color.primary)
  253. .activityBackgroundTint(colorScheme == .light ? Color.white.opacity(0.43) : Color.black.opacity(0.43))
  254. } else {
  255. HStack(spacing: 3) {
  256. LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title)
  257. Spacer()
  258. VStack(alignment: .trailing, spacing: 5) {
  259. LiveActivityGlucoseDeltaLabelView(
  260. context: context,
  261. glucoseColor: hasStaticColorScheme ? .primary : glucoseColor
  262. ).font(.title3)
  263. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: false).font(.caption)
  264. .foregroundStyle(.primary.opacity(0.7))
  265. }
  266. }
  267. .privacySensitive()
  268. .padding(.all, 15)
  269. .foregroundStyle(Color.primary)
  270. .activityBackgroundTint(colorScheme == .light ? Color.white.opacity(0.43) : Color.black.opacity(0.43))
  271. }
  272. }
  273. }
  274. // Separate the smaller sections into reusable views
  275. struct LiveActivityBGAndTrendView: View {
  276. var context: ActivityViewContext<LiveActivityAttributes>
  277. fileprivate var size: Size
  278. var glucoseColor: Color
  279. var body: some View {
  280. let (view, _) = bgAndTrend(context: context, size: size, glucoseColor: glucoseColor)
  281. return view
  282. }
  283. }
  284. struct LiveActivityBGLabelView: View {
  285. var context: ActivityViewContext<LiveActivityAttributes>
  286. var additionalState: LiveActivityAttributes.ContentAdditionalState
  287. var body: some View {
  288. Text(context.state.bg)
  289. .fontWeight(.bold)
  290. .font(.title3)
  291. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  292. }
  293. }
  294. struct LiveActivityGlucoseDeltaLabelView: View {
  295. var context: ActivityViewContext<LiveActivityAttributes>
  296. var glucoseColor: Color
  297. var body: some View {
  298. if !context.state.change.isEmpty {
  299. Text(context.state.change).foregroundStyle(.primary)
  300. .foregroundStyle(context.state.glucoseColorScheme == "staticColor" ? .primary : glucoseColor)
  301. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  302. } else {
  303. Text("--")
  304. }
  305. }
  306. }
  307. struct LiveActivityIOBLabelView: View {
  308. var context: ActivityViewContext<LiveActivityAttributes>
  309. var additionalState: LiveActivityAttributes.ContentAdditionalState
  310. private var bolusFormatter: NumberFormatter {
  311. let formatter = NumberFormatter()
  312. formatter.numberStyle = .decimal
  313. formatter.maximumFractionDigits = 1
  314. formatter.decimalSeparator = "."
  315. return formatter
  316. }
  317. var body: some View {
  318. VStack(spacing: 2) {
  319. HStack {
  320. Text(
  321. bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--"
  322. ).fontWeight(.bold).font(.title3).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  323. Text("U").foregroundStyle(.primary).font(.headline).fontWeight(.bold)
  324. }
  325. Text("IOB").font(.subheadline).foregroundStyle(.primary)
  326. }
  327. }
  328. }
  329. struct LiveActivityCOBLabelView: View {
  330. var context: ActivityViewContext<LiveActivityAttributes>
  331. var additionalState: LiveActivityAttributes.ContentAdditionalState
  332. var body: some View {
  333. VStack(spacing: 2) {
  334. HStack {
  335. Text(
  336. "\(additionalState.cob)"
  337. ).fontWeight(.bold).font(.title3).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  338. Text("g").foregroundStyle(.primary).font(.headline).fontWeight(.bold)
  339. }
  340. Text("COB").font(.subheadline).foregroundStyle(.primary)
  341. }
  342. }
  343. }
  344. struct LiveActivityUpdatedLabelView: View {
  345. var context: ActivityViewContext<LiveActivityAttributes>
  346. var isDetailedLayout: Bool
  347. private var dateFormatter: DateFormatter {
  348. let formatter = DateFormatter()
  349. formatter.dateStyle = .none
  350. formatter.timeStyle = .short
  351. return formatter
  352. }
  353. var body: some View {
  354. if isDetailedLayout {
  355. let dateText = Text("\(dateFormatter.string(from: context.state.date))").font(.title3)
  356. .foregroundStyle(.primary)
  357. VStack {
  358. if context.isStale {
  359. if #available(iOSApplicationExtension 17.0, *) {
  360. dateText.bold().foregroundStyle(.red)
  361. } else {
  362. dateText.bold().foregroundColor(.red)
  363. }
  364. } else {
  365. if #available(iOSApplicationExtension 17.0, *) {
  366. dateText.bold().foregroundStyle(.primary)
  367. } else {
  368. dateText.bold().foregroundColor(.primary)
  369. }
  370. }
  371. Text("Updated").font(.subheadline).foregroundStyle(.primary)
  372. }
  373. } else {
  374. let dateText = Text("\(dateFormatter.string(from: context.state.date))").font(.subheadline)
  375. .foregroundStyle(.secondary)
  376. HStack {
  377. Text("Updated:").font(.subheadline).foregroundStyle(.secondary)
  378. if context.isStale {
  379. if #available(iOSApplicationExtension 17.0, *) {
  380. dateText.bold().foregroundStyle(.red)
  381. } else {
  382. dateText.bold().foregroundColor(.red)
  383. }
  384. } else {
  385. if #available(iOSApplicationExtension 17.0, *) {
  386. dateText.bold().foregroundStyle(.primary)
  387. } else {
  388. dateText.bold().foregroundColor(.primary)
  389. }
  390. }
  391. }
  392. }
  393. }
  394. }
  395. struct LiveActivityChartView: View {
  396. var context: ActivityViewContext<LiveActivityAttributes>
  397. var additionalState: LiveActivityAttributes.ContentAdditionalState
  398. var body: some View {
  399. let state = context.state
  400. let isMgdL: Bool = additionalState.unit == "mg/dL"
  401. // Determine scale
  402. let minValue = min(additionalState.chart.min() ?? 39, 39) as Decimal
  403. let maxValue = max(additionalState.chart.max() ?? 300, 300) as Decimal
  404. let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
  405. .asMmolL
  406. let yAxisRuleMarkMax = isMgdL ? state.highGlucose : state.highGlucose
  407. .asMmolL
  408. let target = isMgdL ? state.target : state.target.asMmolL
  409. let isOverrideActive = additionalState.isOverrideActive == true
  410. let calendar = Calendar.current
  411. let now = Date()
  412. let startDate = calendar.date(byAdding: .hour, value: -6, to: now) ?? now
  413. let endDate = isOverrideActive ? (calendar.date(byAdding: .hour, value: 2, to: now) ?? now) : now
  414. // 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
  415. let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
  416. let hardCodedHigh = isMgdL ? Decimal(220) : 220.asMmolL
  417. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  418. let highColor = Color.getDynamicGlucoseColor(
  419. glucoseValue: yAxisRuleMarkMax,
  420. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  421. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  422. targetGlucose: target,
  423. glucoseColorScheme: context.state.glucoseColorScheme
  424. )
  425. let lowColor = Color.getDynamicGlucoseColor(
  426. glucoseValue: yAxisRuleMarkMin,
  427. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : yAxisRuleMarkMax,
  428. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : yAxisRuleMarkMin,
  429. targetGlucose: target,
  430. glucoseColorScheme: context.state.glucoseColorScheme
  431. )
  432. Chart {
  433. RuleMark(y: .value("High", yAxisRuleMarkMax))
  434. .foregroundStyle(highColor)
  435. .lineStyle(.init(lineWidth: 0.5, dash: [5]))
  436. RuleMark(y: .value("Low", yAxisRuleMarkMin))
  437. .foregroundStyle(lowColor)
  438. .lineStyle(.init(lineWidth: 0.5, dash: [5]))
  439. RuleMark(y: .value("Target", target))
  440. .foregroundStyle(.green.gradient)
  441. .lineStyle(.init(lineWidth: 1))
  442. if isOverrideActive {
  443. drawActiveOverrides()
  444. }
  445. drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
  446. }
  447. .chartYAxis {
  448. AxisMarks(position: .trailing) { _ in
  449. AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white)
  450. AxisValueLabel().foregroundStyle(.primary).font(.footnote)
  451. }
  452. }
  453. .chartYScale(domain: additionalState.unit == "mg/dL" ? minValue ... maxValue : minValue.asMmolL ... maxValue.asMmolL)
  454. .chartYAxis(.hidden)
  455. .chartPlotStyle { plotContent in
  456. plotContent
  457. .background(
  458. RoundedRectangle(cornerRadius: 12)
  459. .fill(Color.clear)
  460. )
  461. .clipShape(RoundedRectangle(cornerRadius: 12))
  462. }
  463. .chartXScale(domain: startDate ... endDate)
  464. .chartXAxis {
  465. AxisMarks(position: .automatic) { _ in
  466. AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.primary)
  467. }
  468. }
  469. }
  470. private func drawActiveOverrides() -> some ChartContent {
  471. let start: Date = context.state.detailedViewState?.overrideDate ?? .distantPast
  472. let duration = context.state.detailedViewState?.overrideDuration ?? 0
  473. let durationAsTimeInterval = TimeInterval((duration as NSDecimalNumber).doubleValue * 60) // return seconds
  474. let end: Date = start.addingTimeInterval(durationAsTimeInterval)
  475. let target = context.state.detailedViewState?.overrideTarget ?? 0
  476. return RuleMark(
  477. xStart: .value("Start", start, unit: .second),
  478. xEnd: .value("End", end, unit: .second),
  479. y: .value("Value", target)
  480. )
  481. .foregroundStyle(Color.purple.opacity(0.6))
  482. .lineStyle(.init(lineWidth: 8))
  483. }
  484. private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
  485. ForEach(additionalState.chart.indices, id: \.self) { index in
  486. let isMgdL = additionalState.unit == "mg/dL"
  487. let currentValue = additionalState.chart[index]
  488. let displayValue = isMgdL ? currentValue : currentValue.asMmolL
  489. let chartDate = additionalState.chartDate[index] ?? Date()
  490. // 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
  491. let hardCodedLow = Decimal(55)
  492. let hardCodedHigh = Decimal(220)
  493. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  494. let pointMarkColor = Color.getDynamicGlucoseColor(
  495. glucoseValue: currentValue,
  496. highGlucoseColorValue: !hasStaticColorScheme ? hardCodedHigh : context.state.highGlucose,
  497. lowGlucoseColorValue: !hasStaticColorScheme ? hardCodedLow : context.state.lowGlucose,
  498. targetGlucose: context.state.target,
  499. glucoseColorScheme: context.state.glucoseColorScheme
  500. )
  501. let pointMark = PointMark(
  502. x: .value("Time", chartDate),
  503. y: .value("Value", displayValue)
  504. ).symbolSize(15)
  505. pointMark.foregroundStyle(pointMarkColor)
  506. }
  507. }
  508. }
  509. // Expanded, minimal, compact view components
  510. struct LiveActivityExpandedLeadingView: View {
  511. var context: ActivityViewContext<LiveActivityAttributes>
  512. var glucoseColor: Color
  513. var body: some View {
  514. LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title2)
  515. .padding(.leading, 5)
  516. }
  517. }
  518. struct LiveActivityExpandedTrailingView: View {
  519. var context: ActivityViewContext<LiveActivityAttributes>
  520. var glucoseColor: Color
  521. var body: some View {
  522. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).font(.title2).padding(.trailing, 5)
  523. }
  524. }
  525. struct LiveActivityExpandedBottomView: View {
  526. var context: ActivityViewContext<LiveActivityAttributes>
  527. var body: some View {
  528. if context.state.isInitialState {
  529. Text("Live Activity Expired. Open Trio to Refresh")
  530. } else if let detailedViewState = context.state.detailedViewState {
  531. LiveActivityChartView(context: context, additionalState: detailedViewState)
  532. }
  533. }
  534. }
  535. struct LiveActivityExpandedCenterView: View {
  536. var context: ActivityViewContext<LiveActivityAttributes>
  537. var body: some View {
  538. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: false).font(.caption).foregroundStyle(Color.secondary)
  539. }
  540. }
  541. struct LiveActivityCompactLeadingView: View {
  542. var context: ActivityViewContext<LiveActivityAttributes>
  543. var glucoseColor: Color
  544. var body: some View {
  545. LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor).padding(.leading, 4)
  546. }
  547. }
  548. struct LiveActivityCompactTrailingView: View {
  549. var context: ActivityViewContext<LiveActivityAttributes>
  550. var glucoseColor: Color
  551. var body: some View {
  552. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).padding(.trailing, 4)
  553. }
  554. }
  555. struct LiveActivityMinimalView: View {
  556. var context: ActivityViewContext<LiveActivityAttributes>
  557. var glucoseColor: Color
  558. var body: some View {
  559. let (label, characterCount) = bgAndTrend(context: context, size: .minimal, glucoseColor: glucoseColor)
  560. let adjustedLabel = label.padding(.leading, 5).padding(.trailing, 2)
  561. if characterCount < 4 {
  562. adjustedLabel.fontWidth(.condensed)
  563. } else if characterCount < 5 {
  564. adjustedLabel.fontWidth(.compressed)
  565. } else {
  566. adjustedLabel.fontWidth(.compressed)
  567. }
  568. }
  569. }
  570. private func bgAndTrend(
  571. context: ActivityViewContext<LiveActivityAttributes>,
  572. size: Size,
  573. glucoseColor: Color
  574. ) -> (some View, Int) {
  575. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  576. var characters = 0
  577. let bgText = context.state.bg
  578. characters += bgText.count
  579. // narrow mode is for the minimal dynamic island view
  580. // there is not enough space to show all three arrow there
  581. // and everything has to be squeezed together to some degree
  582. // only display the first arrow character and make it red in case there were more characters
  583. var directionText: String?
  584. if let direction = context.state.direction {
  585. if size == .compact || size == .minimal {
  586. directionText = String(direction[direction.startIndex ... direction.startIndex])
  587. } else {
  588. directionText = direction
  589. }
  590. characters += directionText!.count
  591. }
  592. let spacing: CGFloat
  593. switch size {
  594. case .minimal: spacing = -1
  595. case .compact: spacing = 0
  596. case .expanded: spacing = 3
  597. }
  598. let stack = HStack(spacing: spacing) {
  599. Text(bgText)
  600. .foregroundColor(hasStaticColorScheme ? .primary : glucoseColor)
  601. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  602. if let direction = directionText {
  603. let text = Text(direction)
  604. switch size {
  605. case .minimal:
  606. let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
  607. scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  608. case .compact:
  609. text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
  610. case .expanded:
  611. text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
  612. }
  613. }
  614. }.foregroundColor(context.isStale ? Color.primary.opacity(0.5) : (hasStaticColorScheme ? .primary : glucoseColor))
  615. return (stack, characters)
  616. }
  617. // Mock structure to replace GlucoseData
  618. struct MockGlucoseData {
  619. var glucose: Int
  620. var date: Date
  621. var direction: String? // You can refine this based on your expected data
  622. }
  623. private extension LiveActivityAttributes {
  624. static var preview: LiveActivityAttributes {
  625. LiveActivityAttributes(startDate: Date())
  626. }
  627. }
  628. private extension LiveActivityAttributes.ContentState {
  629. static var chartData: [MockGlucoseData] = [
  630. MockGlucoseData(glucose: 120, date: Date().addingTimeInterval(-600), direction: "flat"),
  631. MockGlucoseData(glucose: 125, date: Date().addingTimeInterval(-300), direction: "flat"),
  632. MockGlucoseData(glucose: 130, date: Date(), direction: "flat")
  633. ]
  634. static var detailedViewState = LiveActivityAttributes.ContentAdditionalState(
  635. chart: chartData.map { Decimal($0.glucose) },
  636. chartDate: chartData.map(\.date),
  637. rotationDegrees: 0,
  638. cob: 20,
  639. iob: 1.5,
  640. unit: GlucoseUnits.mgdL.rawValue,
  641. isOverrideActive: false,
  642. overrideName: "Exercise",
  643. overrideDate: Date().addingTimeInterval(-3600),
  644. overrideDuration: 120,
  645. overrideTarget: 150,
  646. itemOrder: LiveActivityAttributes.ItemOrder.defaultOrders,
  647. showCOB: true,
  648. showIOB: true,
  649. showCurrentGlucose: true,
  650. showUpdatedLabel: true
  651. )
  652. // 0 is the widest digit. Use this to get an upper bound on text width.
  653. // 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
  654. static var testWide: LiveActivityAttributes.ContentState {
  655. LiveActivityAttributes.ContentState(
  656. bg: "00.0",
  657. direction: "→",
  658. change: "+0.0",
  659. date: Date(),
  660. highGlucose: 180,
  661. lowGlucose: 70,
  662. target: 100,
  663. glucoseColorScheme: "staticColor",
  664. detailedViewState: nil,
  665. isInitialState: false
  666. )
  667. }
  668. static var testVeryWide: LiveActivityAttributes.ContentState {
  669. LiveActivityAttributes.ContentState(
  670. bg: "00.0",
  671. direction: "↑↑",
  672. change: "+0.0",
  673. date: Date(),
  674. highGlucose: 180,
  675. lowGlucose: 70,
  676. target: 100,
  677. glucoseColorScheme: "staticColor",
  678. detailedViewState: nil,
  679. isInitialState: false
  680. )
  681. }
  682. static var testSuperWide: LiveActivityAttributes.ContentState {
  683. LiveActivityAttributes.ContentState(
  684. bg: "00.0",
  685. direction: "↑↑↑",
  686. change: "+0.0",
  687. date: Date(),
  688. highGlucose: 180,
  689. lowGlucose: 70,
  690. target: 100,
  691. glucoseColorScheme: "staticColor",
  692. detailedViewState: nil,
  693. isInitialState: false
  694. )
  695. }
  696. // 2 characters for BG, 1 character for change is the minimum that will be shown
  697. static var testNarrow: LiveActivityAttributes.ContentState {
  698. LiveActivityAttributes.ContentState(
  699. bg: "00",
  700. direction: "↑",
  701. change: "+0",
  702. date: Date(),
  703. highGlucose: 180,
  704. lowGlucose: 70,
  705. target: 100,
  706. glucoseColorScheme: "staticColor",
  707. detailedViewState: nil,
  708. isInitialState: false
  709. )
  710. }
  711. static var testMedium: LiveActivityAttributes.ContentState {
  712. LiveActivityAttributes.ContentState(
  713. bg: "000",
  714. direction: "↗︎",
  715. change: "+00",
  716. date: Date(),
  717. highGlucose: 180,
  718. lowGlucose: 70,
  719. target: 100,
  720. glucoseColorScheme: "staticColor",
  721. detailedViewState: nil,
  722. isInitialState: false
  723. )
  724. }
  725. static var testExpired: LiveActivityAttributes.ContentState {
  726. LiveActivityAttributes.ContentState(
  727. bg: "--",
  728. direction: nil,
  729. change: "--",
  730. date: Date().addingTimeInterval(-60 * 60),
  731. highGlucose: 180,
  732. lowGlucose: 70,
  733. target: 100,
  734. glucoseColorScheme: "staticColor",
  735. detailedViewState: nil,
  736. isInitialState: false
  737. )
  738. }
  739. static var testWideDetailed: LiveActivityAttributes.ContentState {
  740. LiveActivityAttributes.ContentState(
  741. bg: "00.0",
  742. direction: "→",
  743. change: "+0.0",
  744. date: Date(),
  745. highGlucose: 180,
  746. lowGlucose: 70,
  747. target: 100,
  748. glucoseColorScheme: "staticColor",
  749. detailedViewState: detailedViewState,
  750. isInitialState: false
  751. )
  752. }
  753. static var testVeryWideDetailed: LiveActivityAttributes.ContentState {
  754. LiveActivityAttributes.ContentState(
  755. bg: "00.0",
  756. direction: "↑↑",
  757. change: "+0.0",
  758. date: Date(),
  759. highGlucose: 180,
  760. lowGlucose: 70,
  761. target: 100,
  762. glucoseColorScheme: "staticColor",
  763. detailedViewState: detailedViewState,
  764. isInitialState: false
  765. )
  766. }
  767. static var testSuperWideDetailed: LiveActivityAttributes.ContentState {
  768. LiveActivityAttributes.ContentState(
  769. bg: "00.0",
  770. direction: "↑↑↑",
  771. change: "+0.0",
  772. date: Date(),
  773. highGlucose: 180,
  774. lowGlucose: 70,
  775. target: 100,
  776. glucoseColorScheme: "staticColor",
  777. detailedViewState: detailedViewState,
  778. isInitialState: false
  779. )
  780. }
  781. // 2 characters for BG, 1 character for change is the minimum that will be shown
  782. static var testNarrowDetailed: LiveActivityAttributes.ContentState {
  783. LiveActivityAttributes.ContentState(
  784. bg: "00",
  785. direction: "↑",
  786. change: "+0",
  787. date: Date(),
  788. highGlucose: 180,
  789. lowGlucose: 70,
  790. target: 100,
  791. glucoseColorScheme: "staticColor",
  792. detailedViewState: detailedViewState,
  793. isInitialState: false
  794. )
  795. }
  796. static var testMediumDetailed: LiveActivityAttributes.ContentState {
  797. LiveActivityAttributes.ContentState(
  798. bg: "000",
  799. direction: "↗︎",
  800. change: "+00",
  801. date: Date(),
  802. highGlucose: 180,
  803. lowGlucose: 70,
  804. target: 100,
  805. glucoseColorScheme: "staticColor",
  806. detailedViewState: detailedViewState,
  807. isInitialState: false
  808. )
  809. }
  810. static var testExpiredDetailed: LiveActivityAttributes.ContentState {
  811. LiveActivityAttributes.ContentState(
  812. bg: "--",
  813. direction: nil,
  814. change: "--",
  815. date: Date().addingTimeInterval(-60 * 60),
  816. highGlucose: 180,
  817. lowGlucose: 70,
  818. target: 100,
  819. glucoseColorScheme: "staticColor",
  820. detailedViewState: detailedViewState,
  821. isInitialState: false
  822. )
  823. }
  824. }
  825. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  826. #Preview("Simple", as: .content, using: LiveActivityAttributes.preview) {
  827. LiveActivity()
  828. } contentStates: {
  829. LiveActivityAttributes.ContentState.testSuperWide
  830. LiveActivityAttributes.ContentState.testVeryWide
  831. LiveActivityAttributes.ContentState.testWide
  832. LiveActivityAttributes.ContentState.testMedium
  833. LiveActivityAttributes.ContentState.testNarrow
  834. LiveActivityAttributes.ContentState.testExpired
  835. }
  836. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  837. #Preview("Detailed", as: .content, using: LiveActivityAttributes.preview) {
  838. LiveActivity()
  839. } contentStates: {
  840. LiveActivityAttributes.ContentState.testSuperWideDetailed
  841. LiveActivityAttributes.ContentState.testVeryWideDetailed
  842. LiveActivityAttributes.ContentState.testWideDetailed
  843. LiveActivityAttributes.ContentState.testMediumDetailed
  844. LiveActivityAttributes.ContentState.testNarrowDetailed
  845. LiveActivityAttributes.ContentState.testExpiredDetailed
  846. }