LiveActivity.swift 37 KB

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