LiveActivity.swift 35 KB

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