| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810 |
- // LoopFollow
- // LoopFollowLiveActivity.swift
- import ActivityKit
- import SwiftUI
- import WidgetKit
- /// Builds the shared Dynamic Island content used by the Live Activity widget.
- private func makeDynamicIsland(context: ActivityViewContext<GlucoseLiveActivityAttributes>) -> DynamicIsland {
- DynamicIsland {
- DynamicIslandExpandedRegion(.leading) {
- Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
- DynamicIslandLeadingView(snapshot: context.state.snapshot)
- .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
- }
- .id(context.state.seq)
- }
- DynamicIslandExpandedRegion(.trailing) {
- Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
- DynamicIslandTrailingView(snapshot: context.state.snapshot)
- .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay))
- }
- .id(context.state.seq)
- }
- DynamicIslandExpandedRegion(.bottom) {
- Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) {
- DynamicIslandBottomView(snapshot: context.state.snapshot)
- .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true))
- }
- .id(context.state.seq)
- }
- } compactLeading: {
- DynamicIslandCompactLeadingView(snapshot: context.state.snapshot)
- .id(context.state.seq)
- } compactTrailing: {
- DynamicIslandCompactTrailingView(snapshot: context.state.snapshot)
- .id(context.state.seq)
- } minimal: {
- DynamicIslandMinimalView(snapshot: context.state.snapshot)
- .id(context.state.seq)
- }
- .keylineTint(LAColors.keyline(for: context.state.snapshot).opacity(0.75))
- }
- // MARK: - Live Activity widget
- /// Single widget for the Live Activity. Enables the supplemental `.small` family
- /// (CarPlay Dashboard / Watch Smart Stack) and routes the lock screen layout via
- /// `LockScreenFamilyAdaptiveView`.
- struct LoopFollowLiveActivityWidget: Widget {
- var body: some WidgetConfiguration {
- ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in
- LockScreenFamilyAdaptiveView(state: context.state)
- .id(context.state.seq)
- .background(
- LALivenessMarker(
- seq: context.state.seq,
- producedAt: context.state.producedAt
- )
- )
- .activitySystemActionForegroundColor(.white)
- .contentMargins(.all, 0)
- .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!)
- } dynamicIsland: { context in
- makeDynamicIsland(context: context)
- }
- .supplementalActivityFamilies([.small])
- }
- }
- // MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack)
- /// Reads the activityFamily environment value and routes to the appropriate layout.
- /// - `.small` → CarPlay Dashboard & Watch Smart Stack
- /// - everything else → full lock screen layout
- private struct LockScreenFamilyAdaptiveView: View {
- let state: GlucoseLiveActivityAttributes.ContentState
- @Environment(\.activityFamily) private var activityFamily
- var body: some View {
- if activityFamily == .small {
- SmallFamilyView(snapshot: state.snapshot)
- .activityBackgroundTint(Color.black.opacity(0.25))
- } else {
- LockScreenLiveActivityView(state: state)
- .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot))
- }
- }
- }
- // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack)
- private struct SmallFamilyView: View {
- let snapshot: GlucoseSnapshot
- /// Unit label for the right slot — ISF appends "/U", other glucose slots
- /// use the plain glucose unit, non-glucose slots return nil.
- private func rightSlotUnitLabel(for slot: LiveActivitySlotOption) -> String? {
- guard slot.isGlucoseUnit else { return nil }
- if slot == .isf { return snapshot.unit.displayName + "/U" }
- return snapshot.unit.displayName
- }
- var body: some View {
- let rightSlot = LAAppGroupSettings.smallWidgetSlot()
- HStack(alignment: .center, spacing: 0) {
- VStack(alignment: .leading, spacing: 2) {
- HStack(alignment: .firstTextBaseline, spacing: 4) {
- Text(LAFormat.glucose(snapshot))
- .font(.system(size: 28, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(LAColors.keyline(for: snapshot))
- Text(LAFormat.trendArrow(snapshot))
- .font(.system(size: 22, weight: .semibold, design: .rounded))
- .foregroundStyle(LAColors.keyline(for: snapshot))
- }
- Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
- .font(.system(size: 14, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.85))
- }
- .layoutPriority(1)
- Spacer()
- if rightSlot != .none {
- if let unitLabel = rightSlotUnitLabel(for: rightSlot) {
- // Use ViewThatFits so the unit label appears on surfaces with
- // enough vertical space (CarPlay) and is omitted where it doesn't
- // fit (Watch Smart Stack).
- ViewThatFits(in: .vertical) {
- VStack(alignment: .trailing, spacing: 2) {
- Text(rightSlot.gridLabel)
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.65))
- Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
- .font(.system(size: 20, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- Text(unitLabel)
- .font(.system(size: 11, weight: .regular, design: .rounded))
- .foregroundStyle(.white.opacity(0.55))
- }
- VStack(alignment: .trailing, spacing: 2) {
- Text(rightSlot.gridLabel)
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.65))
- Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
- .font(.system(size: 20, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- }
- }
- } else {
- VStack(alignment: .trailing, spacing: 2) {
- Text(rightSlot.gridLabel)
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.65))
- Text(slotFormattedValue(option: rightSlot, snapshot: snapshot))
- .font(.system(size: 20, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- }
- }
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .padding(10)
- }
- }
- // MARK: - Lock Screen Contract View
- private struct LockScreenLiveActivityView: View {
- let state: GlucoseLiveActivityAttributes.ContentState
- var body: some View {
- let s = state.snapshot
- let slotConfig = LAAppGroupSettings.slots()
- VStack(spacing: 6) {
- HStack(spacing: 12) {
- VStack(alignment: .leading, spacing: 4) {
- HStack(alignment: .firstTextBaseline, spacing: 4) {
- Text(LAFormat.glucose(s))
- .font(.system(size: 46, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.78)
- .allowsTightening(true)
- .layoutPriority(3)
- Text(LAFormat.trendArrow(s))
- .font(.system(size: 32, weight: .bold, design: .rounded))
- .foregroundStyle(.white.opacity(0.95))
- .lineLimit(1)
- .fixedSize(horizontal: true, vertical: false)
- }
- Text("Delta: \(LAFormat.delta(s))")
- .font(.system(size: 15, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.80))
- .lineLimit(1)
- }
- .frame(minWidth: 160, maxWidth: 184, alignment: .leading)
- .layoutPriority(2)
- Rectangle()
- .fill(Color.white.opacity(0.20))
- .frame(width: 1)
- .padding(.vertical, 8)
- VStack(spacing: 8) {
- HStack(spacing: 12) {
- SlotView(option: slotConfig[0], snapshot: s)
- SlotView(option: slotConfig[1], snapshot: s)
- }
- HStack(spacing: 12) {
- SlotView(option: slotConfig[2], snapshot: s)
- SlotView(option: slotConfig[3], snapshot: s)
- }
- }
- .frame(maxWidth: .infinity, alignment: .trailing)
- }
- ActiveAdjustmentsView(snapshot: s)
- Text(LAAppGroupSettings.showDisplayName()
- ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
- : "Last Update: \(LAFormat.updated(s))")
- .font(.system(size: 11, weight: .regular, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.65))
- .frame(maxWidth: .infinity, alignment: .center)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 14)
- .padding(.top, 12)
- .padding(.bottom, 8)
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .stroke(Color.white.opacity(0.20), lineWidth: 1)
- )
- .overlay(
- Group {
- if state.snapshot.isNotLooping {
- ZStack {
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(Color(uiColor: UIColor.systemRed).opacity(0.85))
- Text("Not Looping")
- .font(.system(size: 20, weight: .heavy, design: .rounded))
- .foregroundStyle(.white)
- .tracking(1.5)
- }
- }
- }
- )
- .overlay(
- ZStack {
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(Color.gray.opacity(0.9))
- Text("Tap to update")
- .font(.system(size: 20, weight: .semibold))
- .foregroundStyle(.white)
- }
- .opacity(state.snapshot.showRenewalOverlay ? 1 : 0)
- )
- }
- }
- /// Full-size gray overlay shown 30 minutes before the LA renewal deadline.
- /// Applied to both the lock screen view and each expanded Dynamic Island region.
- private struct RenewalOverlayView: View {
- let show: Bool
- var showText: Bool = false
- var body: some View {
- ZStack {
- Color.gray.opacity(0.9)
- if showText {
- Text("Tap to update")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.white)
- }
- }
- .opacity(show ? 1 : 0)
- }
- }
- private struct MetricBlock: View {
- let label: String
- let value: String
- var body: some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(label)
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.78))
- .lineLimit(1)
- .minimumScaleFactor(0.80)
- Text(value)
- .font(.system(size: 16, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.65)
- .allowsTightening(true)
- .layoutPriority(1)
- }
- .frame(width: 72, alignment: .leading)
- }
- }
- private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String {
- switch option {
- case .none: ""
- case .delta: LAFormat.delta(snapshot)
- case .projectedBG: LAFormat.projected(snapshot)
- case .minMax: LAFormat.minMax(snapshot)
- case .iob: LAFormat.iob(snapshot)
- case .cob: LAFormat.cob(snapshot)
- case .recBolus: LAFormat.recBolus(snapshot)
- case .autosens: LAFormat.autosens(snapshot)
- case .tdd: LAFormat.tdd(snapshot)
- case .basal: LAFormat.basal(snapshot)
- case .pump: LAFormat.pump(snapshot)
- case .pumpBattery: LAFormat.pumpBattery(snapshot)
- case .battery: LAFormat.battery(snapshot)
- case .target: LAFormat.target(snapshot)
- case .isf: LAFormat.isf(snapshot)
- case .carbRatio: LAFormat.carbRatio(snapshot)
- case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime)
- case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime)
- case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime)
- case .carbsToday: LAFormat.carbsToday(snapshot)
- case .override: LAFormat.override(snapshot)
- case .profile: LAFormat.profileName(snapshot)
- }
- }
- private struct SlotView: View {
- let option: LiveActivitySlotOption
- let snapshot: GlucoseSnapshot
- var body: some View {
- if option == .none {
- Color.clear
- .frame(width: 72, height: 36)
- } else {
- MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot))
- }
- }
- }
- /// Conditional row showing the active override and/or temp target with a
- /// self-ticking countdown. Shared by the lock screen card and the expanded
- /// Dynamic Island bottom region; renders nothing when neither is active.
- private struct ActiveAdjustmentsView: View {
- let snapshot: GlucoseSnapshot
- /// Above this remaining time the ticker is skipped (name/value only) —
- /// a multi-hour or multi-day countdown reads poorly in the compact row.
- private static let tickerMaxRemaining: TimeInterval = 2 * 3600
- // Ends already in the past are stale data waiting for the next refresh —
- // drop them rather than render a dead 0:00 timer.
- private var overrideEnd: Date? {
- guard let t = snapshot.overrideEndAt, t > Date().timeIntervalSince1970 else { return nil }
- return Date(timeIntervalSince1970: t)
- }
- private var tempTargetEnd: Date? {
- guard let t = snapshot.tempTargetEndAt, t > Date().timeIntervalSince1970 else { return nil }
- return Date(timeIntervalSince1970: t)
- }
- /// nil end with a non-nil name means indefinite — keep showing the name;
- /// a timed override whose end has passed is hidden entirely.
- private var overrideName: String? {
- guard let name = snapshot.override else { return nil }
- if snapshot.overrideEndAt != nil, overrideEnd == nil { return nil }
- return name
- }
- private var tempTargetText: String? {
- guard tempTargetEnd != nil else { return nil }
- return LAFormat.tempTargetValue(snapshot)
- }
- var body: some View {
- if overrideName != nil || tempTargetText != nil {
- HStack(spacing: 5) {
- Text("⏱")
- .font(.system(size: 11))
- if let name = overrideName {
- Text(name)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- .layoutPriority(1)
- if let end = overrideEnd, end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
- countdown(to: end)
- }
- }
- if overrideName != nil, tempTargetText != nil {
- Text("·")
- .foregroundStyle(.white.opacity(0.5))
- }
- if let tt = tempTargetText, let end = tempTargetEnd {
- Text("TT \(tt)")
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- if end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
- countdown(to: end)
- }
- }
- }
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.85))
- .frame(maxWidth: .infinity, alignment: .center)
- }
- }
- @ViewBuilder
- private func countdown(to end: Date) -> some View {
- // Text(timerInterval:) claims flexible width; cap it so the row stays centered.
- Text(timerInterval: Date() ... end, countsDown: true)
- .multilineTextAlignment(.leading)
- .frame(maxWidth: end.timeIntervalSinceNow >= 3600 ? 62 : 46, alignment: .leading)
- }
- }
- // MARK: - Dynamic Island
- private struct DynamicIslandLeadingView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- Text("⚠️ Not Looping")
- .font(.system(size: 20, weight: .heavy, design: .rounded))
- .foregroundStyle(.white)
- .tracking(1.0)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- } else {
- VStack(alignment: .leading, spacing: 2) {
- HStack(alignment: .firstTextBaseline, spacing: 4) {
- Text(LAFormat.glucose(snapshot))
- .font(.system(size: 28, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(LAColors.keyline(for: snapshot))
- Text(LAFormat.trendArrow(snapshot))
- .font(.system(size: 22, weight: .semibold, design: .rounded))
- .foregroundStyle(LAColors.keyline(for: snapshot))
- }
- Text("\(LAFormat.delta(snapshot)) \(snapshot.unit.displayName)")
- .font(.system(size: 13, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.85))
- }
- }
- }
- }
- private struct DynamicIslandTrailingView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- EmptyView()
- } else {
- let slot = LAAppGroupSettings.smallWidgetSlot()
- if slot != .none {
- VStack(alignment: .trailing, spacing: 2) {
- Text(slot.gridLabel)
- .font(.system(size: 11, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.65))
- Text(slotFormattedValue(option: slot, snapshot: snapshot))
- .font(.system(size: 18, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
- }
- .padding(.trailing, 6)
- }
- }
- }
- }
- private struct DynamicIslandBottomView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- Text("Loop has not reported in 15+ minutes")
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.92))
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- } else {
- VStack(spacing: 2) {
- ActiveAdjustmentsView(snapshot: snapshot)
- Text("Updated at: \(LAFormat.updated(snapshot))")
- .font(.system(size: 13, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.92))
- .lineLimit(1)
- .minimumScaleFactor(0.85)
- }
- }
- }
- }
- private struct DynamicIslandCompactTrailingView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- Text("Not Looping")
- .font(.system(size: 11, weight: .heavy, design: .rounded))
- .foregroundStyle(.white)
- .lineLimit(1)
- .minimumScaleFactor(0.7)
- } else {
- Text(LAFormat.delta(snapshot))
- .font(.system(size: 14, weight: .semibold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white.opacity(0.95))
- }
- }
- }
- private struct DynamicIslandCompactLeadingView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- Text("⚠️")
- .font(.system(size: 14))
- } else {
- Text(LAFormat.glucose(snapshot))
- .font(.system(size: 16, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- }
- }
- }
- private struct DynamicIslandMinimalView: View {
- let snapshot: GlucoseSnapshot
- var body: some View {
- if snapshot.isNotLooping {
- Text("⚠️")
- .font(.system(size: 12))
- } else {
- Text(LAFormat.glucose(snapshot))
- .font(.system(size: 14, weight: .bold, design: .rounded))
- .monospacedDigit()
- .foregroundStyle(.white)
- }
- }
- }
- // MARK: - Formatting
- private enum LAFormat {
- private static let mgdlFormatter: NumberFormatter = {
- let nf = NumberFormatter()
- nf.numberStyle = .decimal
- nf.maximumFractionDigits = 0
- nf.locale = .current
- return nf
- }()
- private static let mmolFormatter: NumberFormatter = {
- let nf = NumberFormatter()
- nf.numberStyle = .decimal
- nf.minimumFractionDigits = 1
- nf.maximumFractionDigits = 1
- nf.locale = .current
- return nf
- }()
- private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String {
- switch unit {
- case .mgdl:
- return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))"
- case .mmol:
- let mmol = GlucoseConversion.toMmol(mgdl)
- return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol)
- }
- }
- static func glucose(_ s: GlucoseSnapshot) -> String {
- formatGlucoseValue(s.glucose, unit: s.unit)
- }
- static func delta(_ s: GlucoseSnapshot) -> String {
- switch s.unit {
- case .mgdl:
- let v = Int(round(s.delta))
- if v == 0 { return "0" }
- return v > 0 ? "+\(v)" : "\(v)"
- case .mmol:
- let mmol = GlucoseConversion.toMmol(s.delta)
- let d = (abs(mmol) < 0.05) ? 0.0 : mmol
- if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" }
- let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d))
- return d > 0 ? "+\(formatted)" : "-\(formatted)"
- }
- }
- static func trendArrow(_ s: GlucoseSnapshot) -> String {
- switch s.trend {
- case .upFast: "↑↑"
- case .up: "↑"
- case .upSlight: "↗"
- case .flat: "→"
- case .downSlight: "↘︎"
- case .down: "↓"
- case .downFast: "↓↓"
- case .unknown: "–"
- }
- }
- static func iob(_ s: GlucoseSnapshot) -> String {
- guard let v = s.iob else { return "—" }
- return String(format: "%.1f", v)
- }
- static func cob(_ s: GlucoseSnapshot) -> String {
- guard let v = s.cob else { return "—" }
- return String(Int(round(v)))
- }
- static func projected(_ s: GlucoseSnapshot) -> String {
- guard let v = s.projected else { return "—" }
- return formatGlucoseValue(v, unit: s.unit)
- }
- private static let ageFormatter: DateComponentsFormatter = {
- let f = DateComponentsFormatter()
- f.unitsStyle = .positional
- f.allowedUnits = [.day, .hour]
- f.zeroFormattingBehavior = [.pad]
- return f
- }()
- static func age(insertTime: TimeInterval) -> String {
- guard insertTime > 0 else { return "—" }
- let secondsAgo = Date().timeIntervalSince1970 - insertTime
- return ageFormatter.string(from: secondsAgo) ?? "—"
- }
- static func recBolus(_ s: GlucoseSnapshot) -> String {
- guard let v = s.recBolus else { return "—" }
- return String(format: "%.2fU", v)
- }
- static func autosens(_ s: GlucoseSnapshot) -> String {
- guard let v = s.autosens else { return "—" }
- return String(format: "%.0f%%", v * 100)
- }
- static func tdd(_ s: GlucoseSnapshot) -> String {
- guard let v = s.tdd else { return "—" }
- return String(format: "%.1fU", v)
- }
- static func basal(_ s: GlucoseSnapshot) -> String {
- s.basalRate.isEmpty ? "—" : s.basalRate
- }
- static func pump(_ s: GlucoseSnapshot) -> String {
- guard let v = s.pumpReservoirU else { return "50+U" }
- return "\(Int(round(v)))U"
- }
- static func pumpBattery(_ s: GlucoseSnapshot) -> String {
- guard let v = s.pumpBattery else { return "—" }
- return String(format: "%.0f%%", v)
- }
- static func battery(_ s: GlucoseSnapshot) -> String {
- guard let v = s.battery else { return "—" }
- return String(format: "%.0f%%", v)
- }
- static func target(_ s: GlucoseSnapshot) -> String {
- guard let low = s.targetLowMgdl, low > 0 else { return "—" }
- let lowStr = formatGlucoseValue(low, unit: s.unit)
- if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 {
- return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))"
- }
- return lowStr
- }
- static func isf(_ s: GlucoseSnapshot) -> String {
- guard let v = s.isfMgdlPerU, v > 0 else { return "—" }
- return formatGlucoseValue(v, unit: s.unit)
- }
- static func carbRatio(_ s: GlucoseSnapshot) -> String {
- guard let v = s.carbRatio, v > 0 else { return "—" }
- return String(format: "%.0fg", v)
- }
- static func carbsToday(_ s: GlucoseSnapshot) -> String {
- guard let v = s.carbsToday else { return "—" }
- return "\(Int(round(v)))g"
- }
- static func minMax(_ s: GlucoseSnapshot) -> String {
- guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" }
- return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))"
- }
- static func override(_ s: GlucoseSnapshot) -> String {
- s.override ?? "—"
- }
- static func tempTargetValue(_ s: GlucoseSnapshot) -> String? {
- guard let tt = s.tempTargetMgdl, tt > 0 else { return nil }
- return formatGlucoseValue(tt, unit: s.unit)
- }
- static func profileName(_ s: GlucoseSnapshot) -> String {
- s.profileName ?? "—"
- }
- private static let hhmmFormatter: DateFormatter = {
- let df = DateFormatter()
- df.locale = .current
- df.timeZone = .current
- df.dateFormat = "HH:mm"
- return df
- }()
- private static let hhmmssFormatter: DateFormatter = {
- let df = DateFormatter()
- df.locale = .current
- df.timeZone = .current
- df.dateFormat = "HH:mm:ss"
- return df
- }()
- static func hhmmss(_ date: Date) -> String {
- hhmmssFormatter.string(from: date)
- }
- static func updated(_ s: GlucoseSnapshot) -> String {
- hhmmFormatter.string(from: s.updatedAt)
- }
- }
- // MARK: - Threshold-driven colors
- private enum LAColors {
- static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color {
- let mgdl = snapshot.glucose
- let t = LAAppGroupSettings.thresholdsMgdl()
- let low = t.low
- let high = t.high
- if mgdl < low {
- let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0))
- let opacity = min(max(raw, 0.48), 0.85)
- return Color(uiColor: UIColor.systemRed).opacity(opacity)
- } else if mgdl > high {
- let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high))
- let opacity = min(max(raw, 0.44), 0.85)
- return Color(uiColor: UIColor.systemOrange).opacity(opacity)
- } else {
- return Color(uiColor: UIColor.systemGreen).opacity(0.36)
- }
- }
- static func keyline(for snapshot: GlucoseSnapshot) -> Color {
- let mgdl = snapshot.glucose
- let t = LAAppGroupSettings.thresholdsMgdl()
- let low = t.low
- let high = t.high
- if mgdl < low {
- return Color(uiColor: UIColor.systemRed)
- } else if mgdl > high {
- return Color(uiColor: UIColor.systemOrange)
- } else {
- return Color(uiColor: UIColor.systemGreen)
- }
- }
- }
|