LoopFollowLiveActivity.swift 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  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. ActiveAdjustmentsView(snapshot: s)
  213. Text(LAAppGroupSettings.showDisplayName()
  214. ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
  215. : "Last Update: \(LAFormat.updated(s))")
  216. .font(.system(size: 11, weight: .regular, design: .rounded))
  217. .monospacedDigit()
  218. .foregroundStyle(.white.opacity(0.65))
  219. .frame(maxWidth: .infinity, alignment: .center)
  220. }
  221. .frame(maxWidth: .infinity, alignment: .leading)
  222. .padding(.horizontal, 14)
  223. .padding(.top, 12)
  224. .padding(.bottom, 8)
  225. .overlay(
  226. RoundedRectangle(cornerRadius: 16, style: .continuous)
  227. .stroke(Color.white.opacity(0.20), lineWidth: 1)
  228. )
  229. .overlay(
  230. Group {
  231. if state.snapshot.isNotLooping {
  232. ZStack {
  233. RoundedRectangle(cornerRadius: 16, style: .continuous)
  234. .fill(Color(uiColor: UIColor.systemRed).opacity(0.85))
  235. Text("Not Looping")
  236. .font(.system(size: 20, weight: .heavy, design: .rounded))
  237. .foregroundStyle(.white)
  238. .tracking(1.5)
  239. }
  240. }
  241. }
  242. )
  243. .overlay(
  244. ZStack {
  245. RoundedRectangle(cornerRadius: 16, style: .continuous)
  246. .fill(Color.gray.opacity(0.9))
  247. Text("Tap to update")
  248. .font(.system(size: 20, weight: .semibold))
  249. .foregroundStyle(.white)
  250. }
  251. .opacity(state.snapshot.showRenewalOverlay ? 1 : 0)
  252. )
  253. }
  254. }
  255. /// Full-size gray overlay shown 30 minutes before the LA renewal deadline.
  256. /// Applied to both the lock screen view and each expanded Dynamic Island region.
  257. private struct RenewalOverlayView: View {
  258. let show: Bool
  259. var showText: Bool = false
  260. var body: some View {
  261. ZStack {
  262. Color.gray.opacity(0.9)
  263. if showText {
  264. Text("Tap to update")
  265. .font(.system(size: 14, weight: .semibold))
  266. .foregroundStyle(.white)
  267. }
  268. }
  269. .opacity(show ? 1 : 0)
  270. }
  271. }
  272. private struct MetricBlock: View {
  273. let label: String
  274. let value: String
  275. var body: some View {
  276. VStack(alignment: .leading, spacing: 2) {
  277. Text(label)
  278. .font(.system(size: 12, weight: .semibold, design: .rounded))
  279. .foregroundStyle(.white.opacity(0.78))
  280. .lineLimit(1)
  281. .minimumScaleFactor(0.80)
  282. Text(value)
  283. .font(.system(size: 16, weight: .bold, design: .rounded))
  284. .monospacedDigit()
  285. .foregroundStyle(.white)
  286. .lineLimit(1)
  287. .minimumScaleFactor(0.65)
  288. .allowsTightening(true)
  289. .layoutPriority(1)
  290. }
  291. .frame(width: 72, alignment: .leading)
  292. }
  293. }
  294. private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String {
  295. switch option {
  296. case .none: ""
  297. case .delta: LAFormat.delta(snapshot)
  298. case .projectedBG: LAFormat.projected(snapshot)
  299. case .minMax: LAFormat.minMax(snapshot)
  300. case .iob: LAFormat.iob(snapshot)
  301. case .cob: LAFormat.cob(snapshot)
  302. case .recBolus: LAFormat.recBolus(snapshot)
  303. case .autosens: LAFormat.autosens(snapshot)
  304. case .tdd: LAFormat.tdd(snapshot)
  305. case .basal: LAFormat.basal(snapshot)
  306. case .pump: LAFormat.pump(snapshot)
  307. case .pumpBattery: LAFormat.pumpBattery(snapshot)
  308. case .battery: LAFormat.battery(snapshot)
  309. case .target: LAFormat.target(snapshot)
  310. case .isf: LAFormat.isf(snapshot)
  311. case .carbRatio: LAFormat.carbRatio(snapshot)
  312. case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime)
  313. case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime)
  314. case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime)
  315. case .carbsToday: LAFormat.carbsToday(snapshot)
  316. case .override: LAFormat.override(snapshot)
  317. case .profile: LAFormat.profileName(snapshot)
  318. }
  319. }
  320. private struct SlotView: View {
  321. let option: LiveActivitySlotOption
  322. let snapshot: GlucoseSnapshot
  323. var body: some View {
  324. if option == .none {
  325. Color.clear
  326. .frame(width: 72, height: 36)
  327. } else {
  328. MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot))
  329. }
  330. }
  331. }
  332. /// Conditional row showing the active override and/or temp target with a
  333. /// self-ticking countdown. Shared by the lock screen card and the expanded
  334. /// Dynamic Island bottom region; renders nothing when neither is active.
  335. private struct ActiveAdjustmentsView: View {
  336. let snapshot: GlucoseSnapshot
  337. /// Above this remaining time the ticker is skipped (name/value only) —
  338. /// a multi-hour or multi-day countdown reads poorly in the compact row.
  339. private static let tickerMaxRemaining: TimeInterval = 2 * 3600
  340. // Ends already in the past are stale data waiting for the next refresh —
  341. // drop them rather than render a dead 0:00 timer.
  342. private var overrideEnd: Date? {
  343. guard let t = snapshot.overrideEndAt, t > Date().timeIntervalSince1970 else { return nil }
  344. return Date(timeIntervalSince1970: t)
  345. }
  346. private var tempTargetEnd: Date? {
  347. guard let t = snapshot.tempTargetEndAt, t > Date().timeIntervalSince1970 else { return nil }
  348. return Date(timeIntervalSince1970: t)
  349. }
  350. /// nil end with a non-nil name means indefinite — keep showing the name;
  351. /// a timed override whose end has passed is hidden entirely.
  352. private var overrideName: String? {
  353. guard let name = snapshot.override else { return nil }
  354. if snapshot.overrideEndAt != nil, overrideEnd == nil { return nil }
  355. return name
  356. }
  357. private var tempTargetText: String? {
  358. guard tempTargetEnd != nil else { return nil }
  359. return LAFormat.tempTargetValue(snapshot)
  360. }
  361. var body: some View {
  362. if overrideName != nil || tempTargetText != nil {
  363. HStack(spacing: 5) {
  364. Text("⏱")
  365. .font(.system(size: 11))
  366. if let name = overrideName {
  367. Text(name)
  368. .lineLimit(1)
  369. .minimumScaleFactor(0.7)
  370. .layoutPriority(1)
  371. if let end = overrideEnd, end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
  372. countdown(to: end)
  373. }
  374. }
  375. if overrideName != nil, tempTargetText != nil {
  376. Text("·")
  377. .foregroundStyle(.white.opacity(0.5))
  378. }
  379. if let tt = tempTargetText, let end = tempTargetEnd {
  380. Text("TT \(tt)")
  381. .lineLimit(1)
  382. .minimumScaleFactor(0.8)
  383. if end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
  384. countdown(to: end)
  385. }
  386. }
  387. }
  388. .font(.system(size: 12, weight: .semibold, design: .rounded))
  389. .monospacedDigit()
  390. .foregroundStyle(.white.opacity(0.85))
  391. .frame(maxWidth: .infinity, alignment: .center)
  392. }
  393. }
  394. @ViewBuilder
  395. private func countdown(to end: Date) -> some View {
  396. // Text(timerInterval:) claims flexible width; cap it so the row stays centered.
  397. Text(timerInterval: Date() ... end, countsDown: true)
  398. .multilineTextAlignment(.leading)
  399. .frame(maxWidth: end.timeIntervalSinceNow >= 3600 ? 62 : 46, alignment: .leading)
  400. }
  401. }
  402. // MARK: - Dynamic Island
  403. private struct DynamicIslandLeadingView: View {
  404. let snapshot: GlucoseSnapshot
  405. var body: some View {
  406. if snapshot.isNotLooping {
  407. Text("⚠️ Not Looping")
  408. .font(.system(size: 20, weight: .heavy, design: .rounded))
  409. .foregroundStyle(.white)
  410. .tracking(1.0)
  411. .lineLimit(1)
  412. .minimumScaleFactor(0.7)
  413. } else {
  414. VStack(alignment: .leading, spacing: 2) {
  415. HStack(alignment: .firstTextBaseline, spacing: 4) {
  416. Text(LAFormat.glucose(snapshot))
  417. .font(.system(size: 28, weight: .bold, design: .rounded))
  418. .monospacedDigit()
  419. .foregroundStyle(LAColors.keyline(for: snapshot))
  420. Text(LAFormat.trendArrow(snapshot))
  421. .font(.system(size: 22, weight: .semibold, design: .rounded))
  422. .foregroundStyle(LAColors.keyline(for: snapshot))
  423. }
  424. Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
  425. .font(.system(size: 13, weight: .semibold, design: .rounded))
  426. .monospacedDigit()
  427. .foregroundStyle(.white.opacity(0.85))
  428. }
  429. }
  430. }
  431. }
  432. private struct DynamicIslandTrailingView: View {
  433. let snapshot: GlucoseSnapshot
  434. var body: some View {
  435. if snapshot.isNotLooping {
  436. EmptyView()
  437. } else {
  438. let slot = LAAppGroupSettings.smallWidgetSlot()
  439. if slot != .none {
  440. VStack(alignment: .trailing, spacing: 2) {
  441. Text(slot.gridLabel)
  442. .font(.system(size: 11, weight: .semibold, design: .rounded))
  443. .foregroundStyle(.white.opacity(0.65))
  444. Text(slotFormattedValue(option: slot, snapshot: snapshot))
  445. .font(.system(size: 18, weight: .bold, design: .rounded))
  446. .monospacedDigit()
  447. .foregroundStyle(.white)
  448. .lineLimit(1)
  449. .minimumScaleFactor(0.8)
  450. }
  451. .padding(.trailing, 6)
  452. }
  453. }
  454. }
  455. }
  456. private struct DynamicIslandBottomView: View {
  457. let snapshot: GlucoseSnapshot
  458. var body: some View {
  459. if snapshot.isNotLooping {
  460. Text("Loop has not reported in 15+ minutes")
  461. .font(.system(size: 12, weight: .semibold, design: .rounded))
  462. .foregroundStyle(.white.opacity(0.92))
  463. .lineLimit(1)
  464. .minimumScaleFactor(0.75)
  465. } else {
  466. VStack(spacing: 2) {
  467. ActiveAdjustmentsView(snapshot: snapshot)
  468. Text("Updated at: \(LAFormat.updated(snapshot))")
  469. .font(.system(size: 13, weight: .semibold, design: .rounded))
  470. .foregroundStyle(.white.opacity(0.92))
  471. .lineLimit(1)
  472. .minimumScaleFactor(0.85)
  473. }
  474. }
  475. }
  476. }
  477. private struct DynamicIslandCompactTrailingView: View {
  478. let snapshot: GlucoseSnapshot
  479. var body: some View {
  480. if snapshot.isNotLooping {
  481. Text("Not Looping")
  482. .font(.system(size: 11, weight: .heavy, design: .rounded))
  483. .foregroundStyle(.white)
  484. .lineLimit(1)
  485. .minimumScaleFactor(0.7)
  486. } else {
  487. Text(LAFormat.delta(snapshot))
  488. .font(.system(size: 14, weight: .semibold, design: .rounded))
  489. .monospacedDigit()
  490. .foregroundStyle(.white.opacity(0.95))
  491. }
  492. }
  493. }
  494. private struct DynamicIslandCompactLeadingView: View {
  495. let snapshot: GlucoseSnapshot
  496. var body: some View {
  497. if snapshot.isNotLooping {
  498. Text("⚠️")
  499. .font(.system(size: 14))
  500. } else {
  501. Text(LAFormat.glucose(snapshot))
  502. .font(.system(size: 16, weight: .bold, design: .rounded))
  503. .monospacedDigit()
  504. .foregroundStyle(.white)
  505. }
  506. }
  507. }
  508. private struct DynamicIslandMinimalView: View {
  509. let snapshot: GlucoseSnapshot
  510. var body: some View {
  511. if snapshot.isNotLooping {
  512. Text("⚠️")
  513. .font(.system(size: 12))
  514. } else {
  515. Text(LAFormat.glucose(snapshot))
  516. .font(.system(size: 14, weight: .bold, design: .rounded))
  517. .monospacedDigit()
  518. .foregroundStyle(.white)
  519. }
  520. }
  521. }
  522. // MARK: - Formatting
  523. private enum LAFormat {
  524. private static let mgdlFormatter: NumberFormatter = {
  525. let nf = NumberFormatter()
  526. nf.numberStyle = .decimal
  527. nf.maximumFractionDigits = 0
  528. nf.locale = .current
  529. return nf
  530. }()
  531. private static let mmolFormatter: NumberFormatter = {
  532. let nf = NumberFormatter()
  533. nf.numberStyle = .decimal
  534. nf.minimumFractionDigits = 1
  535. nf.maximumFractionDigits = 1
  536. nf.locale = .current
  537. return nf
  538. }()
  539. private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String {
  540. switch unit {
  541. case .mgdl:
  542. return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))"
  543. case .mmol:
  544. let mmol = GlucoseConversion.toMmol(mgdl)
  545. return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol)
  546. }
  547. }
  548. static func glucose(_ s: GlucoseSnapshot) -> String {
  549. formatGlucoseValue(s.glucose, unit: s.unit)
  550. }
  551. static func delta(_ s: GlucoseSnapshot) -> String {
  552. switch s.unit {
  553. case .mgdl:
  554. let v = Int(round(s.delta))
  555. if v == 0 { return "0" }
  556. return v > 0 ? "+\(v)" : "\(v)"
  557. case .mmol:
  558. let mmol = GlucoseConversion.toMmol(s.delta)
  559. let d = (abs(mmol) < 0.05) ? 0.0 : mmol
  560. if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" }
  561. let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d))
  562. return d > 0 ? "+\(formatted)" : "-\(formatted)"
  563. }
  564. }
  565. static func trendArrow(_ s: GlucoseSnapshot) -> String {
  566. switch s.trend {
  567. case .upFast: "↑↑"
  568. case .up: "↑"
  569. case .upSlight: "↗"
  570. case .flat: "→"
  571. case .downSlight: "↘︎"
  572. case .down: "↓"
  573. case .downFast: "↓↓"
  574. case .unknown: "–"
  575. }
  576. }
  577. static func iob(_ s: GlucoseSnapshot) -> String {
  578. guard let v = s.iob else { return "—" }
  579. return String(format: "%.1f", v)
  580. }
  581. static func cob(_ s: GlucoseSnapshot) -> String {
  582. guard let v = s.cob else { return "—" }
  583. return String(Int(round(v)))
  584. }
  585. static func projected(_ s: GlucoseSnapshot) -> String {
  586. guard let v = s.projected else { return "—" }
  587. return formatGlucoseValue(v, unit: s.unit)
  588. }
  589. private static let ageFormatter: DateComponentsFormatter = {
  590. let f = DateComponentsFormatter()
  591. f.unitsStyle = .positional
  592. f.allowedUnits = [.day, .hour]
  593. f.zeroFormattingBehavior = [.pad]
  594. return f
  595. }()
  596. static func age(insertTime: TimeInterval) -> String {
  597. guard insertTime > 0 else { return "—" }
  598. let secondsAgo = Date().timeIntervalSince1970 - insertTime
  599. return ageFormatter.string(from: secondsAgo) ?? "—"
  600. }
  601. static func recBolus(_ s: GlucoseSnapshot) -> String {
  602. guard let v = s.recBolus else { return "—" }
  603. return String(format: "%.2fU", v)
  604. }
  605. static func autosens(_ s: GlucoseSnapshot) -> String {
  606. guard let v = s.autosens else { return "—" }
  607. return String(format: "%.0f%%", v * 100)
  608. }
  609. static func tdd(_ s: GlucoseSnapshot) -> String {
  610. guard let v = s.tdd else { return "—" }
  611. return String(format: "%.1fU", v)
  612. }
  613. static func basal(_ s: GlucoseSnapshot) -> String {
  614. s.basalRate.isEmpty ? "—" : s.basalRate
  615. }
  616. static func pump(_ s: GlucoseSnapshot) -> String {
  617. guard let v = s.pumpReservoirU else { return "50+U" }
  618. return "\(Int(round(v)))U"
  619. }
  620. static func pumpBattery(_ s: GlucoseSnapshot) -> String {
  621. guard let v = s.pumpBattery else { return "—" }
  622. return String(format: "%.0f%%", v)
  623. }
  624. static func battery(_ s: GlucoseSnapshot) -> String {
  625. guard let v = s.battery else { return "—" }
  626. return String(format: "%.0f%%", v)
  627. }
  628. static func target(_ s: GlucoseSnapshot) -> String {
  629. guard let low = s.targetLowMgdl, low > 0 else { return "—" }
  630. let lowStr = formatGlucoseValue(low, unit: s.unit)
  631. if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 {
  632. return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))"
  633. }
  634. return lowStr
  635. }
  636. static func isf(_ s: GlucoseSnapshot) -> String {
  637. guard let v = s.isfMgdlPerU, v > 0 else { return "—" }
  638. return formatGlucoseValue(v, unit: s.unit)
  639. }
  640. static func carbRatio(_ s: GlucoseSnapshot) -> String {
  641. guard let v = s.carbRatio, v > 0 else { return "—" }
  642. return String(format: "%.0fg", v)
  643. }
  644. static func carbsToday(_ s: GlucoseSnapshot) -> String {
  645. guard let v = s.carbsToday else { return "—" }
  646. return "\(Int(round(v)))g"
  647. }
  648. static func minMax(_ s: GlucoseSnapshot) -> String {
  649. guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" }
  650. return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))"
  651. }
  652. static func override(_ s: GlucoseSnapshot) -> String {
  653. s.override ?? "—"
  654. }
  655. static func tempTargetValue(_ s: GlucoseSnapshot) -> String? {
  656. guard let tt = s.tempTargetMgdl, tt > 0 else { return nil }
  657. return formatGlucoseValue(tt, unit: s.unit)
  658. }
  659. static func profileName(_ s: GlucoseSnapshot) -> String {
  660. s.profileName ?? "—"
  661. }
  662. private static let hhmmFormatter: DateFormatter = {
  663. let df = DateFormatter()
  664. df.locale = .current
  665. df.timeZone = .current
  666. df.dateFormat = "HH:mm"
  667. return df
  668. }()
  669. private static let hhmmssFormatter: DateFormatter = {
  670. let df = DateFormatter()
  671. df.locale = .current
  672. df.timeZone = .current
  673. df.dateFormat = "HH:mm:ss"
  674. return df
  675. }()
  676. static func hhmmss(_ date: Date) -> String {
  677. hhmmssFormatter.string(from: date)
  678. }
  679. static func updated(_ s: GlucoseSnapshot) -> String {
  680. hhmmFormatter.string(from: s.updatedAt)
  681. }
  682. }
  683. // MARK: - Threshold-driven colors
  684. private enum LAColors {
  685. static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color {
  686. let mgdl = snapshot.glucose
  687. let t = LAAppGroupSettings.thresholdsMgdl()
  688. let low = t.low
  689. let high = t.high
  690. if mgdl < low {
  691. let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0))
  692. let opacity = min(max(raw, 0.48), 0.85)
  693. return Color(uiColor: UIColor.systemRed).opacity(opacity)
  694. } else if mgdl > high {
  695. let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high))
  696. let opacity = min(max(raw, 0.44), 0.85)
  697. return Color(uiColor: UIColor.systemOrange).opacity(opacity)
  698. } else {
  699. return Color(uiColor: UIColor.systemGreen).opacity(0.36)
  700. }
  701. }
  702. static func keyline(for snapshot: GlucoseSnapshot) -> Color {
  703. let mgdl = snapshot.glucose
  704. let t = LAAppGroupSettings.thresholdsMgdl()
  705. let low = t.low
  706. let high = t.high
  707. if mgdl < low {
  708. return Color(uiColor: UIColor.systemRed)
  709. } else if mgdl > high {
  710. return Color(uiColor: UIColor.systemOrange)
  711. } else {
  712. return Color(uiColor: UIColor.systemGreen)
  713. }
  714. }
  715. }