LoopFollowLiveActivity.swift 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. // LoopFollow
  2. // LoopFollowLiveActivity.swift
  3. import ActivityKit
  4. import SwiftUI
  5. import WidgetKit
  6. /// Builds the shared Dynamic Island content used by the Live Activity widget.
  7. private func makeDynamicIsland(context: ActivityViewContext<GlucoseLiveActivityAttributes>) -> DynamicIsland {
  8. DynamicIsland {
  9. DynamicIslandExpandedRegion(.leading) {
  10. Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
  11. DynamicIslandLeadingView(snapshot: context.state.snapshot)
  12. .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
  13. }
  14. .id(context.state.seq)
  15. }
  16. DynamicIslandExpandedRegion(.trailing) {
  17. Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
  18. DynamicIslandTrailingView(snapshot: context.state.snapshot)
  19. .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
  20. }
  21. .id(context.state.seq)
  22. }
  23. DynamicIslandExpandedRegion(.bottom) {
  24. Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
  25. DynamicIslandBottomView(snapshot: context.state.snapshot)
  26. .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true))
  27. }
  28. .id(context.state.seq)
  29. }
  30. } compactLeading: {
  31. DynamicIslandCompactLeadingView(snapshot: context.state.snapshot)
  32. .id(context.state.seq)
  33. } compactTrailing: {
  34. DynamicIslandCompactTrailingView(snapshot: context.state.snapshot)
  35. .id(context.state.seq)
  36. } minimal: {
  37. DynamicIslandMinimalView(snapshot: context.state.snapshot)
  38. .id(context.state.seq)
  39. }
  40. .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75))
  41. }
  42. // MARK: - Live Activity widget
  43. /// Single widget for all supported OS versions.
  44. /// - iOS 18+: enables supplemental `.small` family and routes via `LockScreenFamilyAdaptiveView`.
  45. /// - iOS 16.1–17.x: uses the regular lock screen view.
  46. @available(iOSApplicationExtension 16.1, *)
  47. struct LoopFollowLiveActivityWidget: Widget {
  48. var body: some WidgetConfiguration {
  49. if #available(iOSApplicationExtension 18.0, *) {
  50. return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
  51. LockScreenFamilyAdaptiveView(state: context.state)
  52. .id(context.state.seq)
  53. .background(
  54. LALivenessMarker(
  55. seq: context.state.seq,
  56. producedAt: context.state.producedAt
  57. )
  58. )
  59. .activitySystemActionForegroundColor(.white)
  60. .applyActivityContentMarginsFixIfAvailable()
  61. .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
  62. } dynamicIsland: { context in
  63. makeDynamicIsland(context: context)
  64. }
  65. .supplementalActivityFamilies([.small])
  66. } else {
  67. return ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
  68. LockScreenLiveActivityView(state: context.state)
  69. .id(context.state.seq)
  70. .background(
  71. LALivenessMarker(
  72. seq: context.state.seq,
  73. producedAt: context.state.producedAt
  74. )
  75. )
  76. .activitySystemActionForegroundColor(.white)
  77. .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot))
  78. .applyActivityContentMarginsFixIfAvailable()
  79. .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
  80. } dynamicIsland: { context in
  81. makeDynamicIsland(context: context)
  82. }
  83. }
  84. }
  85. }
  86. // MARK: - Live Activity content margins helper
  87. private extension View {
  88. @ViewBuilder
  89. func applyActivityContentMarginsFixIfAvailable() -> some View {
  90. if #available(iOS 17.0, *) {
  91. contentMargins(Edge.Set.all, 0)
  92. } else {
  93. self
  94. }
  95. }
  96. }
  97. // MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack)
  98. /// Reads the activityFamily environment value and routes to the appropriate layout.
  99. /// - `.small` → CarPlay Dashboard & Watch Smart Stack
  100. /// - everything else → full lock screen layout
  101. @available(iOS 18.0, *)
  102. private struct LockScreenFamilyAdaptiveView: View {
  103. let state: GlucoseLiveActivityAttributes.ContentState
  104. @Environment(\.activityFamily) private var activityFamily
  105. var body: some View {
  106. if activityFamily == .small {
  107. SmallFamilyView(snapshot: state.snapshot)
  108. .activityBackgroundTint(Color.black.opacity(0.25))
  109. } else {
  110. LockScreenLiveActivityView(state: state)
  111. .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot))
  112. }
  113. }
  114. }
  115. // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack)
  116. @available(iOS 18.0, *)
  117. private struct SmallFamilyView: View {
  118. let snapshot: GlucoseSnapshot
  119. /// Unit label for the right slot — ISF appends "/U", other glucose slots
  120. /// use the plain glucose unit, non-glucose slots return nil.
  121. private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? {
  122. guard slot.isGlucoseUnit else { return nil }
  123. if slot == .isf { return snapshot.unit.displayName + "/U" }
  124. return snapshot.unit.displayName
  125. }
  126. var body: some View {
  127. let rightSlot = LAAppGroupSettings.smallWidgetSlot()
  128. HStack(alignment: .center, spacing: 0) {
  129. VStack(alignment: .leading, spacing: 2) {
  130. HStack(alignment: .firstTextBaseline, spacing: 4) {
  131. Text(LAFormat.glucose(snapshot))
  132. .font(.system(size: 28, weight: .bold, design: .rounded))
  133. .monospacedDigit()
  134. .foregroundStyle(LAColors.keyline(for: snapshot))
  135. Text(LAFormat.trendArrow(snapshot))
  136. .font(.system(size: 22, weight: .semibold, design: .rounded))
  137. .foregroundStyle(LAColors.keyline(for: snapshot))
  138. }
  139. Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
  140. .font(.system(size: 14, weight: .semibold, design: .rounded))
  141. .monospacedDigit()
  142. .foregroundStyle(.white.opacity(0.85))
  143. }
  144. .layoutPriority(1)
  145. Spacer()
  146. if rightSlot != .none {
  147. if let unitLabel = rightSlotUnitLabel(for: rightSlot) {
  148. // Use ViewThatFits so the unit label appears on surfaces with
  149. // enough vertical space (CarPlay) and is omitted where it doesn't
  150. // fit (Watch Smart Stack).
  151. ViewThatFits(in: .vertical) {
  152. VStack(alignment: .trailing, spacing: 2) {
  153. Text(rightSlot.gridLabel)
  154. .font(.system(size: 12, weight: .semibold, design: .rounded))
  155. .foregroundStyle(.white.opacity(0.65))
  156. Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
  157. .font(.system(size: 20, weight: .bold, design: .rounded))
  158. .monospacedDigit()
  159. .foregroundStyle(.white)
  160. .lineLimit(1)
  161. .minimumScaleFactor(0.8)
  162. Text(unitLabel)
  163. .font(.system(size: 11, weight: .regular, design: .rounded))
  164. .foregroundStyle(.white.opacity(0.55))
  165. }
  166. VStack(alignment: .trailing, spacing: 2) {
  167. Text(rightSlot.gridLabel)
  168. .font(.system(size: 12, weight: .semibold, design: .rounded))
  169. .foregroundStyle(.white.opacity(0.65))
  170. Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
  171. .font(.system(size: 20, weight: .bold, design: .rounded))
  172. .monospacedDigit()
  173. .foregroundStyle(.white)
  174. .lineLimit(1)
  175. .minimumScaleFactor(0.8)
  176. }
  177. }
  178. } else {
  179. VStack(alignment: .trailing, spacing: 2) {
  180. Text(rightSlot.gridLabel)
  181. .font(.system(size: 12, weight: .semibold, design: .rounded))
  182. .foregroundStyle(.white.opacity(0.65))
  183. Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
  184. .font(.system(size: 20, weight: .bold, design: .rounded))
  185. .monospacedDigit()
  186. .foregroundStyle(.white)
  187. .lineLimit(1)
  188. .minimumScaleFactor(0.8)
  189. }
  190. }
  191. }
  192. }
  193. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
  194. .padding(10)
  195. }
  196. }
  197. // MARK: - Lock Screen Contract View
  198. private struct LockScreenLiveActivityView: View {
  199. let state: GlucoseLiveActivityAttributes.ContentState
  200. var body: some View {
  201. let s = state.snapshot
  202. let slotConfig = LAAppGroupSettings.slots()
  203. VStack(spacing: 6) {
  204. HStack(spacing: 12) {
  205. VStack(alignment: .leading, spacing: 4) {
  206. HStack(alignment: .firstTextBaseline, spacing: 4) {
  207. Text(LAFormat.glucose(s))
  208. .font(.system(size: 46, weight: .bold, design: .rounded))
  209. .monospacedDigit()
  210. .foregroundStyle(.white)
  211. .lineLimit(1)
  212. .minimumScaleFactor(0.78)
  213. .allowsTightening(true)
  214. .layoutPriority(3)
  215. Text(LAFormat.trendArrow(s))
  216. .font(.system(size: 32, weight: .bold, design: .rounded))
  217. .foregroundStyle(.white.opacity(0.95))
  218. .lineLimit(1)
  219. .fixedSize(horizontal: true, vertical: false)
  220. }
  221. Text("Delta: \(LAFormat.delta(s))")
  222. .font(.system(size: 15, weight: .semibold, design: .rounded))
  223. .monospacedDigit()
  224. .foregroundStyle(.white.opacity(0.80))
  225. .lineLimit(1)
  226. }
  227. .frame(minWidth: 160, maxWidth: 184, alignment: .leading)
  228. .layoutPriority(2)
  229. Rectangle()
  230. .fill(Color.white.opacity(0.20))
  231. .frame(width: 1)
  232. .padding(.vertical, 8)
  233. VStack(spacing: 8) {
  234. HStack(spacing: 12) {
  235. SlotView(option: slotConfig[0], snapshot: s)
  236. SlotView(option: slotConfig[1], snapshot: s)
  237. }
  238. HStack(spacing: 12) {
  239. SlotView(option: slotConfig[2], snapshot: s)
  240. SlotView(option: slotConfig[3], snapshot: s)
  241. }
  242. }
  243. .frame(maxWidth: .infinity, alignment: .trailing)
  244. }
  245. Text(LAAppGroupSettings.showDisplayName()
  246. ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
  247. : "Last Update: \(LAFormat.updated(s))")
  248. .font(.system(size: 11, weight: .regular, design: .rounded))
  249. .monospacedDigit()
  250. .foregroundStyle(.white.opacity(0.65))
  251. .frame(maxWidth: .infinity, alignment: .center)
  252. }
  253. .frame(maxWidth: .infinity, alignment: .leading)
  254. .padding(.horizontal, 14)
  255. .padding(.top, 12)
  256. .padding(.bottom, 8)
  257. .overlay(
  258. RoundedRectangle(cornerRadius: 16, style: .continuous)
  259. .stroke(Color.white.opacity(0.20), lineWidth: 1)
  260. )
  261. .overlay(
  262. Group {
  263. if state.snapshot.isNotLooping {
  264. ZStack {
  265. RoundedRectangle(cornerRadius: 16, style: .continuous)
  266. .fill(Color(uiColor: UIColor.systemRed).opacity(0.85))
  267. Text("Not Looping")
  268. .font(.system(size: 20, weight: .heavy, design: .rounded))
  269. .foregroundStyle(.white)
  270. .tracking(1.5)
  271. }
  272. }
  273. }
  274. )
  275. .overlay(
  276. ZStack {
  277. RoundedRectangle(cornerRadius: 16, style: .continuous)
  278. .fill(Color.gray.opacity(0.9))
  279. Text("Tap to update")
  280. .font(.system(size: 20, weight: .semibold))
  281. .foregroundStyle(.white)
  282. }
  283. .opacity(state.snapshot.showRenewalOverlay ? 1 : 0)
  284. )
  285. }
  286. }
  287. /// Full-size gray overlay shown 30 minutes before the LA renewal deadline.
  288. /// Applied to both the lock screen view and each expanded Dynamic Island region.
  289. private struct RenewalOverlayView: View {
  290. let show: Bool
  291. var showText: Bool = false
  292. var body: some View {
  293. ZStack {
  294. Color.gray.opacity(0.9)
  295. if showText {
  296. Text("Tap to update")
  297. .font(.system(size: 14, weight: .semibold))
  298. .foregroundStyle(.white)
  299. }
  300. }
  301. .opacity(show ? 1 : 0)
  302. }
  303. }
  304. private struct MetricBlock: View {
  305. let label: String
  306. let value: String
  307. var body: some View {
  308. VStack(alignment: .leading, spacing: 2) {
  309. Text(label)
  310. .font(.system(size: 12, weight: .semibold, design: .rounded))
  311. .foregroundStyle(.white.opacity(0.78))
  312. .lineLimit(1)
  313. .minimumScaleFactor(0.80)
  314. Text(value)
  315. .font(.system(size: 16, weight: .bold, design: .rounded))
  316. .monospacedDigit()
  317. .foregroundStyle(.white)
  318. .lineLimit(1)
  319. .minimumScaleFactor(0.65)
  320. .allowsTightening(true)
  321. .layoutPriority(1)
  322. }
  323. .frame(width: 72, alignment: .leading)
  324. }
  325. }
  326. private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String {
  327. switch option {
  328. case .none: ""
  329. case .delta: LAFormat.delta(snapshot)
  330. case .projectedBG: LAFormat.projected(snapshot)
  331. case .minMax: LAFormat.minMax(snapshot)
  332. case .iob: LAFormat.iob(snapshot)
  333. case .cob: LAFormat.cob(snapshot)
  334. case .recBolus: LAFormat.recBolus(snapshot)
  335. case .autosens: LAFormat.autosens(snapshot)
  336. case .tdd: LAFormat.tdd(snapshot)
  337. case .basal: LAFormat.basal(snapshot)
  338. case .pump: LAFormat.pump(snapshot)
  339. case .pumpBattery: LAFormat.pumpBattery(snapshot)
  340. case .battery: LAFormat.battery(snapshot)
  341. case .target: LAFormat.target(snapshot)
  342. case .isf: LAFormat.isf(snapshot)
  343. case .carbRatio: LAFormat.carbRatio(snapshot)
  344. case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime)
  345. case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime)
  346. case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime)
  347. case .carbsToday: LAFormat.carbsToday(snapshot)
  348. case .override: LAFormat.override(snapshot)
  349. case .profile: LAFormat.profileName(snapshot)
  350. }
  351. }
  352. private struct SlotView: View {
  353. let option: LiveActivitySlotOption
  354. let snapshot: GlucoseSnapshot
  355. var body: some View {
  356. if option == .none {
  357. Color.clear
  358. .frame(width: 72, height: 36)
  359. } else {
  360. MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot))
  361. }
  362. }
  363. }
  364. // MARK: - Dynamic Island
  365. private struct DynamicIslandLeadingView: View {
  366. let snapshot: GlucoseSnapshot
  367. var body: some View {
  368. if snapshot.isNotLooping {
  369. (Text(verbatim: "⚠️ ") + Text("Not Looping"))
  370. .font(.system(size: 20, weight: .heavy, design: .rounded))
  371. .foregroundStyle(.white)
  372. .tracking(1.0)
  373. .lineLimit(1)
  374. .minimumScaleFactor(0.7)
  375. } else {
  376. VStack(alignment: .leading, spacing: 2) {
  377. HStack(alignment: .firstTextBaseline, spacing: 4) {
  378. Text(LAFormat.glucose(snapshot))
  379. .font(.system(size: 28, weight: .bold, design: .rounded))
  380. .monospacedDigit()
  381. .foregroundStyle(LAColors.keyline(for: snapshot))
  382. Text(LAFormat.trendArrow(snapshot))
  383. .font(.system(size: 22, weight: .semibold, design: .rounded))
  384. .foregroundStyle(LAColors.keyline(for: snapshot))
  385. }
  386. Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
  387. .font(.system(size: 13, weight: .semibold, design: .rounded))
  388. .monospacedDigit()
  389. .foregroundStyle(.white.opacity(0.85))
  390. }
  391. }
  392. }
  393. }
  394. private struct DynamicIslandTrailingView: View {
  395. let snapshot: GlucoseSnapshot
  396. var body: some View {
  397. if snapshot.isNotLooping {
  398. EmptyView()
  399. } else {
  400. let slot = LAAppGroupSettings.smallWidgetSlot()
  401. if slot != .none {
  402. VStack(alignment: .trailing, spacing: 2) {
  403. Text(slot.gridLabel)
  404. .font(.system(size: 11, weight: .semibold, design: .rounded))
  405. .foregroundStyle(.white.opacity(0.65))
  406. Text(slotFormattedValue(option: slot, snapshot: snapshot))
  407. .font(.system(size: 18, weight: .bold, design: .rounded))
  408. .monospacedDigit()
  409. .foregroundStyle(.white)
  410. .lineLimit(1)
  411. .minimumScaleFactor(0.8)
  412. }
  413. .padding(.trailing, 6)
  414. }
  415. }
  416. }
  417. }
  418. private struct DynamicIslandBottomView: View {
  419. let snapshot: GlucoseSnapshot
  420. var body: some View {
  421. if snapshot.isNotLooping {
  422. Text("Loop has not reported in 15+ minutes")
  423. .font(.system(size: 12, weight: .semibold, design: .rounded))
  424. .foregroundStyle(.white.opacity(0.92))
  425. .lineLimit(1)
  426. .minimumScaleFactor(0.75)
  427. } else {
  428. Text("Updated at: \(LAFormat.updated(snapshot))")
  429. .font(.system(size: 13, weight: .semibold, design: .rounded))
  430. .foregroundStyle(.white.opacity(0.92))
  431. .lineLimit(1)
  432. .minimumScaleFactor(0.85)
  433. }
  434. }
  435. }
  436. private struct DynamicIslandCompactTrailingView: View {
  437. let snapshot: GlucoseSnapshot
  438. var body: some View {
  439. if snapshot.isNotLooping {
  440. Text("Not Looping")
  441. .font(.system(size: 11, weight: .heavy, design: .rounded))
  442. .foregroundStyle(.white)
  443. .lineLimit(1)
  444. .minimumScaleFactor(0.7)
  445. } else {
  446. Text(LAFormat.delta(snapshot))
  447. .font(.system(size: 14, weight: .semibold, design: .rounded))
  448. .monospacedDigit()
  449. .foregroundStyle(.white.opacity(0.95))
  450. }
  451. }
  452. }
  453. private struct DynamicIslandCompactLeadingView: View {
  454. let snapshot: GlucoseSnapshot
  455. var body: some View {
  456. if snapshot.isNotLooping {
  457. Text("⚠️")
  458. .font(.system(size: 14))
  459. } else {
  460. Text(LAFormat.glucose(snapshot))
  461. .font(.system(size: 16, weight: .bold, design: .rounded))
  462. .monospacedDigit()
  463. .foregroundStyle(.white)
  464. }
  465. }
  466. }
  467. private struct DynamicIslandMinimalView: View {
  468. let snapshot: GlucoseSnapshot
  469. var body: some View {
  470. if snapshot.isNotLooping {
  471. Text("⚠️")
  472. .font(.system(size: 12))
  473. } else {
  474. Text(LAFormat.glucose(snapshot))
  475. .font(.system(size: 14, weight: .bold, design: .rounded))
  476. .monospacedDigit()
  477. .foregroundStyle(.white)
  478. }
  479. }
  480. }
  481. // MARK: - Formatting
  482. private enum LAFormat {
  483. private static let mgdlFormatter: NumberFormatter = {
  484. let nf = NumberFormatter()
  485. nf.numberStyle = .decimal
  486. nf.maximumFractionDigits = 0
  487. nf.locale = .current
  488. return nf
  489. }()
  490. private static let mmolFormatter: NumberFormatter = {
  491. let nf = NumberFormatter()
  492. nf.numberStyle = .decimal
  493. nf.minimumFractionDigits = 1
  494. nf.maximumFractionDigits = 1
  495. nf.locale = .current
  496. return nf
  497. }()
  498. private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String {
  499. switch unit {
  500. case .mgdl:
  501. return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))"
  502. case .mmol:
  503. let mmol = GlucoseConversion.toMmol(mgdl)
  504. return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol)
  505. }
  506. }
  507. static func glucose(_ s: GlucoseSnapshot) -> String {
  508. formatGlucoseValue(s.glucose, unit: s.unit)
  509. }
  510. static func delta(_ s: GlucoseSnapshot) -> String {
  511. switch s.unit {
  512. case .mgdl:
  513. let v = Int(round(s.delta))
  514. if v == 0 { return "0" }
  515. return v > 0 ? "+\(v)" : "\(v)"
  516. case .mmol:
  517. let mmol = GlucoseConversion.toMmol(s.delta)
  518. let d = (abs(mmol) < 0.05) ? 0.0 : mmol
  519. if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" }
  520. let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d))
  521. return d > 0 ? "+\(formatted)" : "-\(formatted)"
  522. }
  523. }
  524. static func trendArrow(_ s: GlucoseSnapshot) -> String {
  525. switch s.trend {
  526. case .upFast: "↑↑"
  527. case .up: "↑"
  528. case .upSlight: "↗"
  529. case .flat: "→"
  530. case .downSlight: "↘︎"
  531. case .down: "↓"
  532. case .downFast: "↓↓"
  533. case .unknown: "–"
  534. }
  535. }
  536. static func iob(_ s: GlucoseSnapshot) -> String {
  537. guard let v = s.iob else { return "—" }
  538. return String(format: "%.1f", v)
  539. }
  540. static func cob(_ s: GlucoseSnapshot) -> String {
  541. guard let v = s.cob else { return "—" }
  542. return String(Int(round(v)))
  543. }
  544. static func projected(_ s: GlucoseSnapshot) -> String {
  545. guard let v = s.projected else { return "—" }
  546. return formatGlucoseValue(v, unit: s.unit)
  547. }
  548. private static let ageFormatter: DateComponentsFormatter = {
  549. let f = DateComponentsFormatter()
  550. f.unitsStyle = .positional
  551. f.allowedUnits = [.day, .hour]
  552. f.zeroFormattingBehavior = [.pad]
  553. return f
  554. }()
  555. static func age(insertTime: TimeInterval) -> String {
  556. guard insertTime > 0 else { return "—" }
  557. let secondsAgo = Date().timeIntervalSince1970 - insertTime
  558. return ageFormatter.string(from: secondsAgo) ?? "—"
  559. }
  560. static func recBolus(_ s: GlucoseSnapshot) -> String {
  561. guard let v = s.recBolus else { return "—" }
  562. return String(format: "%.2fU", v)
  563. }
  564. static func autosens(_ s: GlucoseSnapshot) -> String {
  565. guard let v = s.autosens else { return "—" }
  566. return String(format: "%.0f%%", v * 100)
  567. }
  568. static func tdd(_ s: GlucoseSnapshot) -> String {
  569. guard let v = s.tdd else { return "—" }
  570. return String(format: "%.1fU", v)
  571. }
  572. static func basal(_ s: GlucoseSnapshot) -> String {
  573. s.basalRate.isEmpty ? "—" : s.basalRate
  574. }
  575. static func pump(_ s: GlucoseSnapshot) -> String {
  576. guard let v = s.pumpReservoirU else { return "50+U" }
  577. return "\(Int(round(v)))U"
  578. }
  579. static func pumpBattery(_ s: GlucoseSnapshot) -> String {
  580. guard let v = s.pumpBattery else { return "—" }
  581. return String(format: "%.0f%%", v)
  582. }
  583. static func battery(_ s: GlucoseSnapshot) -> String {
  584. guard let v = s.battery else { return "—" }
  585. return String(format: "%.0f%%", v)
  586. }
  587. static func target(_ s: GlucoseSnapshot) -> String {
  588. guard let low = s.targetLowMgdl, low > 0 else { return "—" }
  589. let lowStr = formatGlucoseValue(low, unit: s.unit)
  590. if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 {
  591. return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))"
  592. }
  593. return lowStr
  594. }
  595. static func isf(_ s: GlucoseSnapshot) -> String {
  596. guard let v = s.isfMgdlPerU, v > 0 else { return "—" }
  597. return formatGlucoseValue(v, unit: s.unit)
  598. }
  599. static func carbRatio(_ s: GlucoseSnapshot) -> String {
  600. guard let v = s.carbRatio, v > 0 else { return "—" }
  601. return String(format: "%.0fg", v)
  602. }
  603. static func carbsToday(_ s: GlucoseSnapshot) -> String {
  604. guard let v = s.carbsToday else { return "—" }
  605. return "\(Int(round(v)))g"
  606. }
  607. static func minMax(_ s: GlucoseSnapshot) -> String {
  608. guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" }
  609. return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))"
  610. }
  611. static func override(_ s: GlucoseSnapshot) -> String {
  612. s.override ?? "—"
  613. }
  614. static func profileName(_ s: GlucoseSnapshot) -> String {
  615. s.profileName ?? "—"
  616. }
  617. private static let hhmmFormatter: DateFormatter = {
  618. let df = DateFormatter()
  619. df.locale = .current
  620. df.timeZone = .current
  621. df.dateFormat = "HH:mm"
  622. return df
  623. }()
  624. private static let hhmmssFormatter: DateFormatter = {
  625. let df = DateFormatter()
  626. df.locale = .current
  627. df.timeZone = .current
  628. df.dateFormat = "HH:mm:ss"
  629. return df
  630. }()
  631. static func hhmmss(_ date: Date) -> String {
  632. hhmmssFormatter.string(from: date)
  633. }
  634. static func updated(_ s: GlucoseSnapshot) -> String {
  635. hhmmFormatter.string(from: s.updatedAt)
  636. }
  637. }
  638. // MARK: - Threshold-driven colors
  639. private enum LAColors {
  640. static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color {
  641. let mgdl = snapshot.glucose
  642. let t = LAAppGroupSettings.thresholdsMgdl()
  643. let low = t.low
  644. let high = t.high
  645. if mgdl < low {
  646. let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0))
  647. let opacity = min(max(raw, 0.48), 0.85)
  648. return Color(uiColor: UIColor.systemRed).opacity(opacity)
  649. } else if mgdl > high {
  650. let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high))
  651. let opacity = min(max(raw, 0.44), 0.85)
  652. return Color(uiColor: UIColor.systemOrange).opacity(opacity)
  653. } else {
  654. return Color(uiColor: UIColor.systemGreen).opacity(0.36)
  655. }
  656. }
  657. static func keyline(for snapshot: GlucoseSnapshot) -> Color {
  658. let mgdl = snapshot.glucose
  659. let t = LAAppGroupSettings.thresholdsMgdl()
  660. let low = t.low
  661. let high = t.high
  662. if mgdl < low {
  663. return Color(uiColor: UIColor.systemRed)
  664. } else if mgdl > high {
  665. return Color(uiColor: UIColor.systemOrange)
  666. } else {
  667. return Color(uiColor: UIColor.systemGreen)
  668. }
  669. }
  670. }