LiveActivity.swift 34 KB

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