LiveActivity.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  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 = lowGlucoseColorValue - offset
  66. let highGlucose = highGlucoseColorValue + (offset * 1.75)
  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. var 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 minutesAgo = Int(Date().timeIntervalSince(context.state.date) / 60)
  351. let dateText = Text("\(minutesAgo < 1 ? "<1" : minutesAgo.description)m ago").font(.title3)
  352. .foregroundStyle(.primary)
  353. VStack {
  354. if context.isStale {
  355. if #available(iOSApplicationExtension 17.0, *) {
  356. dateText.bold().foregroundStyle(.red)
  357. } else {
  358. dateText.bold().foregroundColor(.red)
  359. }
  360. } else {
  361. if #available(iOSApplicationExtension 17.0, *) {
  362. dateText.bold().foregroundStyle(.primary)
  363. } else {
  364. dateText.bold().foregroundColor(.primary)
  365. }
  366. }
  367. Text("Updated").font(.subheadline).foregroundStyle(.primary)
  368. }
  369. } else {
  370. let dateText = Text("\(dateFormatter.string(from: context.state.date))").font(.subheadline)
  371. .foregroundStyle(.secondary)
  372. HStack {
  373. Text("Updated:").font(.subheadline).foregroundStyle(.secondary)
  374. if context.isStale {
  375. if #available(iOSApplicationExtension 17.0, *) {
  376. dateText.bold().foregroundStyle(.red)
  377. } else {
  378. dateText.bold().foregroundColor(.red)
  379. }
  380. } else {
  381. if #available(iOSApplicationExtension 17.0, *) {
  382. dateText.bold().foregroundStyle(.primary)
  383. } else {
  384. dateText.bold().foregroundColor(.primary)
  385. }
  386. }
  387. }
  388. }
  389. }
  390. }
  391. struct LiveActivityChartView: View {
  392. var context: ActivityViewContext<LiveActivityAttributes>
  393. var additionalState: LiveActivityAttributes.ContentAdditionalState
  394. var body: some View {
  395. let state = context.state
  396. let isMgdl: Bool = additionalState.unit == "mg/dL"
  397. // Determine scale
  398. let minValue = min(additionalState.chart.min() ?? 39, 39) as Decimal
  399. let maxValue = max(additionalState.chart.max() ?? 300, 300) as Decimal
  400. let yAxisRuleMarkMin = isMgdl ? state.lowGlucose : state.lowGlucose
  401. .asMmolL
  402. let yAxisRuleMarkMax = isMgdl ? state.highGlucose : state.highGlucose
  403. .asMmolL
  404. let target = isMgdl ? state.target : state.target.asMmolL
  405. let isOverrideActive = additionalState.isOverrideActive == true
  406. let calendar = Calendar.current
  407. let now = Date()
  408. let startDate = calendar.date(byAdding: .hour, value: -6, to: now) ?? now
  409. let endDate = isOverrideActive ? (calendar.date(byAdding: .hour, value: 2, to: now) ?? now) : now
  410. let highColor = Color.getDynamicGlucoseColor(
  411. glucoseValue: yAxisRuleMarkMax,
  412. highGlucoseColorValue: yAxisRuleMarkMax,
  413. lowGlucoseColorValue: yAxisRuleMarkMin,
  414. targetGlucose: isMgdl ? state.target : state.target.asMmolL,
  415. glucoseColorScheme: context.state.glucoseColorScheme,
  416. offset: isMgdl ? Decimal(20) : Decimal(20).asMmolL
  417. )
  418. let lowColor = Color.getDynamicGlucoseColor(
  419. glucoseValue: yAxisRuleMarkMin,
  420. highGlucoseColorValue: yAxisRuleMarkMax,
  421. lowGlucoseColorValue: yAxisRuleMarkMin,
  422. targetGlucose: isMgdl ? state.target : state.target.asMmolL,
  423. glucoseColorScheme: context.state.glucoseColorScheme,
  424. offset: isMgdl ? Decimal(20) : Decimal(20).asMmolL
  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. let pointMarkColor = Color.getDynamicGlucoseColor(
  485. glucoseValue: currentValue,
  486. highGlucoseColorValue: context.state.highGlucose,
  487. lowGlucoseColorValue: context.state.lowGlucose,
  488. targetGlucose: context.state.target,
  489. glucoseColorScheme: context.state.glucoseColorScheme,
  490. offset: 20
  491. )
  492. let pointMark = PointMark(
  493. x: .value("Time", chartDate),
  494. y: .value("Value", displayValue)
  495. ).symbolSize(15)
  496. pointMark.foregroundStyle(pointMarkColor)
  497. }
  498. }
  499. }
  500. // Expanded, minimal, compact view components
  501. struct LiveActivityExpandedLeadingView: View {
  502. var context: ActivityViewContext<LiveActivityAttributes>
  503. var glucoseColor: Color
  504. var body: some View {
  505. LiveActivityBGAndTrendView(context: context, size: .expanded, glucoseColor: glucoseColor).font(.title2)
  506. .padding(.leading, 5)
  507. }
  508. }
  509. struct LiveActivityExpandedTrailingView: View {
  510. var context: ActivityViewContext<LiveActivityAttributes>
  511. var glucoseColor: Color
  512. var body: some View {
  513. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).font(.title2).padding(.trailing, 5)
  514. }
  515. }
  516. struct LiveActivityExpandedBottomView: View {
  517. var context: ActivityViewContext<LiveActivityAttributes>
  518. var body: some View {
  519. if context.state.isInitialState {
  520. Text("Live Activity Expired. Open Trio to Refresh")
  521. } else if let detailedViewState = context.state.detailedViewState {
  522. LiveActivityChartView(context: context, additionalState: detailedViewState)
  523. }
  524. }
  525. }
  526. struct LiveActivityExpandedCenterView: View {
  527. var context: ActivityViewContext<LiveActivityAttributes>
  528. var body: some View {
  529. LiveActivityUpdatedLabelView(context: context, isDetailedLayout: false).font(.caption).foregroundStyle(Color.secondary)
  530. }
  531. }
  532. struct LiveActivityCompactLeadingView: View {
  533. var context: ActivityViewContext<LiveActivityAttributes>
  534. var glucoseColor: Color
  535. var body: some View {
  536. LiveActivityBGAndTrendView(context: context, size: .compact, glucoseColor: glucoseColor).padding(.leading, 4)
  537. }
  538. }
  539. struct LiveActivityCompactTrailingView: View {
  540. var context: ActivityViewContext<LiveActivityAttributes>
  541. var glucoseColor: Color
  542. var body: some View {
  543. LiveActivityGlucoseDeltaLabelView(context: context, glucoseColor: glucoseColor).padding(.trailing, 4)
  544. }
  545. }
  546. struct LiveActivityMinimalView: View {
  547. var context: ActivityViewContext<LiveActivityAttributes>
  548. var glucoseColor: Color
  549. var body: some View {
  550. let (label, characterCount) = bgAndTrend(context: context, size: .minimal, glucoseColor: glucoseColor)
  551. let adjustedLabel = label.padding(.leading, 5).padding(.trailing, 2)
  552. if characterCount < 4 {
  553. adjustedLabel.fontWidth(.condensed)
  554. } else if characterCount < 5 {
  555. adjustedLabel.fontWidth(.compressed)
  556. } else {
  557. adjustedLabel.fontWidth(.compressed)
  558. }
  559. }
  560. }
  561. private func bgAndTrend(
  562. context: ActivityViewContext<LiveActivityAttributes>,
  563. size: Size,
  564. glucoseColor: Color
  565. ) -> (some View, Int) {
  566. let hasStaticColorScheme = context.state.glucoseColorScheme == "staticColor"
  567. var characters = 0
  568. let bgText = context.state.bg
  569. characters += bgText.count
  570. // narrow mode is for the minimal dynamic island view
  571. // there is not enough space to show all three arrow there
  572. // and everything has to be squeezed together to some degree
  573. // only display the first arrow character and make it red in case there were more characters
  574. var directionText: String?
  575. if let direction = context.state.direction {
  576. if size == .compact || size == .minimal {
  577. directionText = String(direction[direction.startIndex ... direction.startIndex])
  578. } else {
  579. directionText = direction
  580. }
  581. characters += directionText!.count
  582. }
  583. let spacing: CGFloat
  584. switch size {
  585. case .minimal: spacing = -1
  586. case .compact: spacing = 0
  587. case .expanded: spacing = 3
  588. }
  589. let stack = HStack(spacing: spacing) {
  590. Text(bgText)
  591. .foregroundColor(hasStaticColorScheme ? .primary : glucoseColor)
  592. .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
  593. if let direction = directionText {
  594. let text = Text(direction)
  595. switch size {
  596. case .minimal:
  597. let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
  598. scaledText.foregroundStyle(hasStaticColorScheme ? .primary : glucoseColor)
  599. case .compact:
  600. text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
  601. case .expanded:
  602. text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
  603. }
  604. }
  605. }.foregroundColor(context.isStale ? Color.primary.opacity(0.5) : (hasStaticColorScheme ? .primary : glucoseColor))
  606. return (stack, characters)
  607. }
  608. // Mock structure to replace GlucoseData
  609. struct MockGlucoseData {
  610. var glucose: Int
  611. var date: Date
  612. var direction: String? // You can refine this based on your expected data
  613. }
  614. private extension LiveActivityAttributes {
  615. static var preview: LiveActivityAttributes {
  616. LiveActivityAttributes(startDate: Date())
  617. }
  618. }
  619. private extension LiveActivityAttributes.ContentState {
  620. static var chartData: [MockGlucoseData] = [
  621. MockGlucoseData(glucose: 120, date: Date().addingTimeInterval(-600), direction: "flat"),
  622. MockGlucoseData(glucose: 125, date: Date().addingTimeInterval(-300), direction: "flat"),
  623. MockGlucoseData(glucose: 130, date: Date(), direction: "flat")
  624. ]
  625. static var detailedViewState = LiveActivityAttributes.ContentAdditionalState(
  626. chart: chartData.map { Decimal($0.glucose) },
  627. chartDate: chartData.map(\.date),
  628. rotationDegrees: 0,
  629. cob: 20,
  630. iob: 1.5,
  631. unit: GlucoseUnits.mgdL.rawValue,
  632. isOverrideActive: false,
  633. overrideName: "Exercise",
  634. overrideDate: Date().addingTimeInterval(-3600),
  635. overrideDuration: 120,
  636. overrideTarget: 150,
  637. itemOrder: LiveActivityAttributes.ItemOrder.defaultOrders,
  638. showCOB: true,
  639. showIOB: true,
  640. showCurrentGlucose: true,
  641. showUpdatedLabel: true
  642. )
  643. // 0 is the widest digit. Use this to get an upper bound on text width.
  644. // 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
  645. static var testWide: LiveActivityAttributes.ContentState {
  646. LiveActivityAttributes.ContentState(
  647. bg: "00.0",
  648. direction: "→",
  649. change: "+0.0",
  650. date: Date(),
  651. highGlucose: 180,
  652. lowGlucose: 70,
  653. target: 100,
  654. glucoseColorScheme: "staticColor",
  655. detailedViewState: nil,
  656. isInitialState: false
  657. )
  658. }
  659. static var testVeryWide: LiveActivityAttributes.ContentState {
  660. LiveActivityAttributes.ContentState(
  661. bg: "00.0",
  662. direction: "↑↑",
  663. change: "+0.0",
  664. date: Date(),
  665. highGlucose: 180,
  666. lowGlucose: 70,
  667. target: 100,
  668. glucoseColorScheme: "staticColor",
  669. detailedViewState: nil,
  670. isInitialState: false
  671. )
  672. }
  673. static var testSuperWide: LiveActivityAttributes.ContentState {
  674. LiveActivityAttributes.ContentState(
  675. bg: "00.0",
  676. direction: "↑↑↑",
  677. change: "+0.0",
  678. date: Date(),
  679. highGlucose: 180,
  680. lowGlucose: 70,
  681. target: 100,
  682. glucoseColorScheme: "staticColor",
  683. detailedViewState: nil,
  684. isInitialState: false
  685. )
  686. }
  687. // 2 characters for BG, 1 character for change is the minimum that will be shown
  688. static var testNarrow: LiveActivityAttributes.ContentState {
  689. LiveActivityAttributes.ContentState(
  690. bg: "00",
  691. direction: "↑",
  692. change: "+0",
  693. date: Date(),
  694. highGlucose: 180,
  695. lowGlucose: 70,
  696. target: 100,
  697. glucoseColorScheme: "staticColor",
  698. detailedViewState: nil,
  699. isInitialState: false
  700. )
  701. }
  702. static var testMedium: LiveActivityAttributes.ContentState {
  703. LiveActivityAttributes.ContentState(
  704. bg: "000",
  705. direction: "↗︎",
  706. change: "+00",
  707. date: Date(),
  708. highGlucose: 180,
  709. lowGlucose: 70,
  710. target: 100,
  711. glucoseColorScheme: "staticColor",
  712. detailedViewState: nil,
  713. isInitialState: false
  714. )
  715. }
  716. static var testExpired: LiveActivityAttributes.ContentState {
  717. LiveActivityAttributes.ContentState(
  718. bg: "--",
  719. direction: nil,
  720. change: "--",
  721. date: Date().addingTimeInterval(-60 * 60),
  722. highGlucose: 180,
  723. lowGlucose: 70,
  724. target: 100,
  725. glucoseColorScheme: "staticColor",
  726. detailedViewState: nil,
  727. isInitialState: false
  728. )
  729. }
  730. static var testWideDetailed: LiveActivityAttributes.ContentState {
  731. LiveActivityAttributes.ContentState(
  732. bg: "00.0",
  733. direction: "→",
  734. change: "+0.0",
  735. date: Date(),
  736. highGlucose: 180,
  737. lowGlucose: 70,
  738. target: 100,
  739. glucoseColorScheme: "staticColor",
  740. detailedViewState: detailedViewState,
  741. isInitialState: false
  742. )
  743. }
  744. static var testVeryWideDetailed: LiveActivityAttributes.ContentState {
  745. LiveActivityAttributes.ContentState(
  746. bg: "00.0",
  747. direction: "↑↑",
  748. change: "+0.0",
  749. date: Date(),
  750. highGlucose: 180,
  751. lowGlucose: 70,
  752. target: 100,
  753. glucoseColorScheme: "staticColor",
  754. detailedViewState: detailedViewState,
  755. isInitialState: false
  756. )
  757. }
  758. static var testSuperWideDetailed: LiveActivityAttributes.ContentState {
  759. LiveActivityAttributes.ContentState(
  760. bg: "00.0",
  761. direction: "↑↑↑",
  762. change: "+0.0",
  763. date: Date(),
  764. highGlucose: 180,
  765. lowGlucose: 70,
  766. target: 100,
  767. glucoseColorScheme: "staticColor",
  768. detailedViewState: detailedViewState,
  769. isInitialState: false
  770. )
  771. }
  772. // 2 characters for BG, 1 character for change is the minimum that will be shown
  773. static var testNarrowDetailed: LiveActivityAttributes.ContentState {
  774. LiveActivityAttributes.ContentState(
  775. bg: "00",
  776. direction: "↑",
  777. change: "+0",
  778. date: Date(),
  779. highGlucose: 180,
  780. lowGlucose: 70,
  781. target: 100,
  782. glucoseColorScheme: "staticColor",
  783. detailedViewState: detailedViewState,
  784. isInitialState: false
  785. )
  786. }
  787. static var testMediumDetailed: LiveActivityAttributes.ContentState {
  788. LiveActivityAttributes.ContentState(
  789. bg: "000",
  790. direction: "↗︎",
  791. change: "+00",
  792. date: Date(),
  793. highGlucose: 180,
  794. lowGlucose: 70,
  795. target: 100,
  796. glucoseColorScheme: "staticColor",
  797. detailedViewState: detailedViewState,
  798. isInitialState: false
  799. )
  800. }
  801. static var testExpiredDetailed: LiveActivityAttributes.ContentState {
  802. LiveActivityAttributes.ContentState(
  803. bg: "--",
  804. direction: nil,
  805. change: "--",
  806. date: Date().addingTimeInterval(-60 * 60),
  807. highGlucose: 180,
  808. lowGlucose: 70,
  809. target: 100,
  810. glucoseColorScheme: "staticColor",
  811. detailedViewState: detailedViewState,
  812. isInitialState: false
  813. )
  814. }
  815. }
  816. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  817. #Preview("Simple", as: .content, using: LiveActivityAttributes.preview) {
  818. LiveActivity()
  819. } contentStates: {
  820. LiveActivityAttributes.ContentState.testSuperWide
  821. LiveActivityAttributes.ContentState.testVeryWide
  822. LiveActivityAttributes.ContentState.testWide
  823. LiveActivityAttributes.ContentState.testMedium
  824. LiveActivityAttributes.ContentState.testNarrow
  825. LiveActivityAttributes.ContentState.testExpired
  826. }
  827. @available(iOS 17.0, iOSApplicationExtension 17.0, *)
  828. #Preview("Detailed", as: .content, using: LiveActivityAttributes.preview) {
  829. LiveActivity()
  830. } contentStates: {
  831. LiveActivityAttributes.ContentState.testSuperWideDetailed
  832. LiveActivityAttributes.ContentState.testVeryWideDetailed
  833. LiveActivityAttributes.ContentState.testWideDetailed
  834. LiveActivityAttributes.ContentState.testMediumDetailed
  835. LiveActivityAttributes.ContentState.testNarrowDetailed
  836. LiveActivityAttributes.ContentState.testExpiredDetailed
  837. }