LoopFollowLiveActivity.swift 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  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: 168, maxWidth: 190, 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. Text(value)
  313. .font(.system(size: 16, weight: .bold, design: .rounded))
  314. .monospacedDigit()
  315. .foregroundStyle(.white)
  316. .lineLimit(1)
  317. .minimumScaleFactor(0.85)
  318. }
  319. .frame(width: 60, alignment: .leading)
  320. }
  321. }
  322. private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String {
  323. switch option {
  324. case .none: ""
  325. case .delta: LAFormat.delta(snapshot)
  326. case .projectedBG: LAFormat.projected(snapshot)
  327. case .minMax: LAFormat.minMax(snapshot)
  328. case .iob: LAFormat.iob(snapshot)
  329. case .cob: LAFormat.cob(snapshot)
  330. case .recBolus: LAFormat.recBolus(snapshot)
  331. case .autosens: LAFormat.autosens(snapshot)
  332. case .tdd: LAFormat.tdd(snapshot)
  333. case .basal: LAFormat.basal(snapshot)
  334. case .pump: LAFormat.pump(snapshot)
  335. case .pumpBattery: LAFormat.pumpBattery(snapshot)
  336. case .battery: LAFormat.battery(snapshot)
  337. case .target: LAFormat.target(snapshot)
  338. case .isf: LAFormat.isf(snapshot)
  339. case .carbRatio: LAFormat.carbRatio(snapshot)
  340. case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime)
  341. case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime)
  342. case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime)
  343. case .carbsToday: LAFormat.carbsToday(snapshot)
  344. case .override: LAFormat.override(snapshot)
  345. case .profile: LAFormat.profileName(snapshot)
  346. }
  347. }
  348. private struct SlotView: View {
  349. let option: LiveActivitySlotOption
  350. let snapshot: GlucoseSnapshot
  351. var body: some View {
  352. if option == .none {
  353. Color.clear
  354. .frame(width: 60, height: 36)
  355. } else {
  356. MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot))
  357. }
  358. }
  359. }
  360. // MARK: - Dynamic Island
  361. private struct DynamicIslandLeadingView: View {
  362. let snapshot: GlucoseSnapshot
  363. var body: some View {
  364. if snapshot.isNotLooping {
  365. Text("⚠️ Not Looping")
  366. .font(.system(size: 20, weight: .heavy, design: .rounded))
  367. .foregroundStyle(.white)
  368. .tracking(1.0)
  369. .lineLimit(1)
  370. .minimumScaleFactor(0.7)
  371. } else {
  372. VStack(alignment: .leading, spacing: 2) {
  373. HStack(alignment: .firstTextBaseline, spacing: 4) {
  374. Text(LAFormat.glucose(snapshot))
  375. .font(.system(size: 28, weight: .bold, design: .rounded))
  376. .monospacedDigit()
  377. .foregroundStyle(LAColors.keyline(for: snapshot))
  378. Text(LAFormat.trendArrow(snapshot))
  379. .font(.system(size: 22, weight: .semibold, design: .rounded))
  380. .foregroundStyle(LAColors.keyline(for: snapshot))
  381. }
  382. Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
  383. .font(.system(size: 13, weight: .semibold, design: .rounded))
  384. .monospacedDigit()
  385. .foregroundStyle(.white.opacity(0.85))
  386. }
  387. }
  388. }
  389. }
  390. private struct DynamicIslandTrailingView: View {
  391. let snapshot: GlucoseSnapshot
  392. var body: some View {
  393. if snapshot.isNotLooping {
  394. EmptyView()
  395. } else {
  396. let slot = LAAppGroupSettings.smallWidgetSlot()
  397. if slot != .none {
  398. VStack(alignment: .trailing, spacing: 2) {
  399. Text(slot.gridLabel)
  400. .font(.system(size: 11, weight: .semibold, design: .rounded))
  401. .foregroundStyle(.white.opacity(0.65))
  402. Text(slotFormattedValue(option: slot, snapshot: snapshot))
  403. .font(.system(size: 18, weight: .bold, design: .rounded))
  404. .monospacedDigit()
  405. .foregroundStyle(.white)
  406. .lineLimit(1)
  407. .minimumScaleFactor(0.8)
  408. }
  409. .padding(.trailing, 6)
  410. }
  411. }
  412. }
  413. }
  414. private struct DynamicIslandBottomView: View {
  415. let snapshot: GlucoseSnapshot
  416. var body: some View {
  417. if snapshot.isNotLooping {
  418. Text("Loop has not reported in 15+ minutes")
  419. .font(.system(size: 12, weight: .semibold, design: .rounded))
  420. .foregroundStyle(.white.opacity(0.92))
  421. .lineLimit(1)
  422. .minimumScaleFactor(0.75)
  423. } else {
  424. Text("Updated at: \(LAFormat.updated(snapshot))")
  425. .font(.system(size: 13, weight: .semibold, design: .rounded))
  426. .foregroundStyle(.white.opacity(0.92))
  427. .lineLimit(1)
  428. .minimumScaleFactor(0.85)
  429. }
  430. }
  431. }
  432. private struct DynamicIslandCompactTrailingView: View {
  433. let snapshot: GlucoseSnapshot
  434. var body: some View {
  435. if snapshot.isNotLooping {
  436. Text("Not Looping")
  437. .font(.system(size: 11, weight: .heavy, design: .rounded))
  438. .foregroundStyle(.white)
  439. .lineLimit(1)
  440. .minimumScaleFactor(0.7)
  441. } else {
  442. Text(LAFormat.delta(snapshot))
  443. .font(.system(size: 14, weight: .semibold, design: .rounded))
  444. .monospacedDigit()
  445. .foregroundStyle(.white.opacity(0.95))
  446. }
  447. }
  448. }
  449. private struct DynamicIslandCompactLeadingView: View {
  450. let snapshot: GlucoseSnapshot
  451. var body: some View {
  452. if snapshot.isNotLooping {
  453. Text("⚠️")
  454. .font(.system(size: 14))
  455. } else {
  456. Text(LAFormat.glucose(snapshot))
  457. .font(.system(size: 16, weight: .bold, design: .rounded))
  458. .monospacedDigit()
  459. .foregroundStyle(.white)
  460. }
  461. }
  462. }
  463. private struct DynamicIslandMinimalView: View {
  464. let snapshot: GlucoseSnapshot
  465. var body: some View {
  466. if snapshot.isNotLooping {
  467. Text("⚠️")
  468. .font(.system(size: 12))
  469. } else {
  470. Text(LAFormat.glucose(snapshot))
  471. .font(.system(size: 14, weight: .bold, design: .rounded))
  472. .monospacedDigit()
  473. .foregroundStyle(.white)
  474. }
  475. }
  476. }
  477. // MARK: - Formatting
  478. private enum LAFormat {
  479. private static let mgdlFormatter: NumberFormatter = {
  480. let nf = NumberFormatter()
  481. nf.numberStyle = .decimal
  482. nf.maximumFractionDigits = 0
  483. nf.locale = .current
  484. return nf
  485. }()
  486. private static let mmolFormatter: NumberFormatter = {
  487. let nf = NumberFormatter()
  488. nf.numberStyle = .decimal
  489. nf.minimumFractionDigits = 1
  490. nf.maximumFractionDigits = 1
  491. nf.locale = .current
  492. return nf
  493. }()
  494. private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String {
  495. switch unit {
  496. case .mgdl:
  497. return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))"
  498. case .mmol:
  499. let mmol = GlucoseConversion.toMmol(mgdl)
  500. return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol)
  501. }
  502. }
  503. static func glucose(_ s: GlucoseSnapshot) -> String {
  504. formatGlucoseValue(s.glucose, unit: s.unit)
  505. }
  506. static func delta(_ s: GlucoseSnapshot) -> String {
  507. switch s.unit {
  508. case .mgdl:
  509. let v = Int(round(s.delta))
  510. if v == 0 { return "0" }
  511. return v > 0 ? "+\(v)" : "\(v)"
  512. case .mmol:
  513. let mmol = GlucoseConversion.toMmol(s.delta)
  514. let d = (abs(mmol) < 0.05) ? 0.0 : mmol
  515. if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" }
  516. let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d))
  517. return d > 0 ? "+\(formatted)" : "-\(formatted)"
  518. }
  519. }
  520. static func trendArrow(_ s: GlucoseSnapshot) -> String {
  521. switch s.trend {
  522. case .upFast: "↑↑"
  523. case .up: "↑"
  524. case .upSlight: "↗"
  525. case .flat: "→"
  526. case .downSlight: "↘︎"
  527. case .down: "↓"
  528. case .downFast: "↓↓"
  529. case .unknown: "–"
  530. }
  531. }
  532. static func iob(_ s: GlucoseSnapshot) -> String {
  533. guard let v = s.iob else { return "—" }
  534. return String(format: "%.1f", v)
  535. }
  536. static func cob(_ s: GlucoseSnapshot) -> String {
  537. guard let v = s.cob else { return "—" }
  538. return String(Int(round(v)))
  539. }
  540. static func projected(_ s: GlucoseSnapshot) -> String {
  541. guard let v = s.projected else { return "—" }
  542. return formatGlucoseValue(v, unit: s.unit)
  543. }
  544. private static let ageFormatter: DateComponentsFormatter = {
  545. let f = DateComponentsFormatter()
  546. f.unitsStyle = .positional
  547. f.allowedUnits = [.day, .hour]
  548. f.zeroFormattingBehavior = [.pad]
  549. return f
  550. }()
  551. static func age(insertTime: TimeInterval) -> String {
  552. guard insertTime > 0 else { return "—" }
  553. let secondsAgo = Date().timeIntervalSince1970 - insertTime
  554. return ageFormatter.string(from: secondsAgo) ?? "—"
  555. }
  556. static func recBolus(_ s: GlucoseSnapshot) -> String {
  557. guard let v = s.recBolus else { return "—" }
  558. return String(format: "%.2fU", v)
  559. }
  560. static func autosens(_ s: GlucoseSnapshot) -> String {
  561. guard let v = s.autosens else { return "—" }
  562. return String(format: "%.0f%%", v * 100)
  563. }
  564. static func tdd(_ s: GlucoseSnapshot) -> String {
  565. guard let v = s.tdd else { return "—" }
  566. return String(format: "%.1fU", v)
  567. }
  568. static func basal(_ s: GlucoseSnapshot) -> String {
  569. s.basalRate.isEmpty ? "—" : s.basalRate
  570. }
  571. static func pump(_ s: GlucoseSnapshot) -> String {
  572. guard let v = s.pumpReservoirU else { return "50+U" }
  573. return "\(Int(round(v)))U"
  574. }
  575. static func pumpBattery(_ s: GlucoseSnapshot) -> String {
  576. guard let v = s.pumpBattery else { return "—" }
  577. return String(format: "%.0f%%", v)
  578. }
  579. static func battery(_ s: GlucoseSnapshot) -> String {
  580. guard let v = s.battery else { return "—" }
  581. return String(format: "%.0f%%", v)
  582. }
  583. static func target(_ s: GlucoseSnapshot) -> String {
  584. guard let low = s.targetLowMgdl, low > 0 else { return "—" }
  585. let lowStr = formatGlucoseValue(low, unit: s.unit)
  586. if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 {
  587. return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))"
  588. }
  589. return lowStr
  590. }
  591. static func isf(_ s: GlucoseSnapshot) -> String {
  592. guard let v = s.isfMgdlPerU, v > 0 else { return "—" }
  593. return formatGlucoseValue(v, unit: s.unit)
  594. }
  595. static func carbRatio(_ s: GlucoseSnapshot) -> String {
  596. guard let v = s.carbRatio, v > 0 else { return "—" }
  597. return String(format: "%.0fg", v)
  598. }
  599. static func carbsToday(_ s: GlucoseSnapshot) -> String {
  600. guard let v = s.carbsToday else { return "—" }
  601. return "\(Int(round(v)))g"
  602. }
  603. static func minMax(_ s: GlucoseSnapshot) -> String {
  604. guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" }
  605. return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))"
  606. }
  607. static func override(_ s: GlucoseSnapshot) -> String {
  608. s.override ?? "—"
  609. }
  610. static func profileName(_ s: GlucoseSnapshot) -> String {
  611. s.profileName ?? "—"
  612. }
  613. private static let hhmmFormatter: DateFormatter = {
  614. let df = DateFormatter()
  615. df.locale = .current
  616. df.timeZone = .current
  617. df.dateFormat = "HH:mm"
  618. return df
  619. }()
  620. private static let hhmmssFormatter: DateFormatter = {
  621. let df = DateFormatter()
  622. df.locale = .current
  623. df.timeZone = .current
  624. df.dateFormat = "HH:mm:ss"
  625. return df
  626. }()
  627. static func hhmmss(_ date: Date) -> String {
  628. hhmmssFormatter.string(from: date)
  629. }
  630. static func updated(_ s: GlucoseSnapshot) -> String {
  631. hhmmFormatter.string(from: s.updatedAt)
  632. }
  633. }
  634. // MARK: - Threshold-driven colors
  635. private enum LAColors {
  636. static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color {
  637. let mgdl = snapshot.glucose
  638. let t = LAAppGroupSettings.thresholdsMgdl()
  639. let low = t.low
  640. let high = t.high
  641. if mgdl < low {
  642. let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0))
  643. let opacity = min(max(raw, 0.48), 0.85)
  644. return Color(uiColor: UIColor.systemRed).opacity(opacity)
  645. } else if mgdl > high {
  646. let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high))
  647. let opacity = min(max(raw, 0.44), 0.85)
  648. return Color(uiColor: UIColor.systemOrange).opacity(opacity)
  649. } else {
  650. return Color(uiColor: UIColor.systemGreen).opacity(0.36)
  651. }
  652. }
  653. static func keyline(for snapshot: GlucoseSnapshot) -> Color {
  654. let mgdl = snapshot.glucose
  655. let t = LAAppGroupSettings.thresholdsMgdl()
  656. let low = t.low
  657. let high = t.high
  658. if mgdl < low {
  659. return Color(uiColor: UIColor.systemRed)
  660. } else if mgdl > high {
  661. return Color(uiColor: UIColor.systemOrange)
  662. } else {
  663. return Color(uiColor: UIColor.systemGreen)
  664. }
  665. }
  666. }