LoopFollowLiveActivity.swift 26 KB

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