From 4f888203992af492a2c6d7a930cb7b2e648b6249 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:54:49 -0400 Subject: [PATCH] Fix range and mmol/L colors in chart and LA - Push an immediate Live Activity update when the custom low/high lines change. TIR/TITR use fixed ranges, so this only applies in custom mode. Thresholds are published through writeThresholds() so the effective range is always used. - Refresh the main stats pie when the range lines or range mode change. - Compare reading colors at mmol/L display precision, so two readings that both display as "3.9" get the same color. Applies to the chart, BG text, contact image and the Live Activity widget. mg/dL is unchanged. - Share the rounding via GlucoseConversion.toDisplayedMmol. --- LoopFollow/Charts/BGChartModel.swift | 6 ++- LoopFollow/Contact/ContactColorMode.swift | 12 +++-- LoopFollow/Helpers/Localizer.swift | 19 +++++++ .../LiveActivity/LiveActivityManager.swift | 52 +++++++++++++++++++ .../ViewControllers/MainViewController.swift | 11 ++-- .../LoopFollowLiveActivity.swift | 40 ++++++++++---- Shared/GlucoseConversion.swift | 5 ++ 7 files changed, 126 insertions(+), 19 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..fb29dad07 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -261,9 +261,11 @@ final class BGChartModel: ObservableObject { } private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color { - if Double(sgv) >= thresholds.high { + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let bgValue = normalize(Double(sgv)) + if bgValue >= normalize(thresholds.high) { return .yellow - } else if Double(sgv) <= thresholds.low { + } else if bgValue <= normalize(thresholds.low) { return .red } else { return .green diff --git a/LoopFollow/Contact/ContactColorMode.swift b/LoopFollow/Contact/ContactColorMode.swift index 7d146040c..f1b86c3d6 100644 --- a/LoopFollow/Contact/ContactColorMode.swift +++ b/LoopFollow/Contact/ContactColorMode.swift @@ -16,18 +16,20 @@ enum ContactColorMode: String, Codable, CaseIterable { } } - /// Returns the appropriate text color based on the mode and BG value + /// Returns the appropriate text color based on the mode and BG value (in mg/dL). func textColor(for bgValue: Double, staticColor: UIColor) -> UIColor { switch self { case .staticColor: return staticColor case .dynamic: - let highLine = Storage.shared.highLine.value - let lowLine = Storage.shared.lowLine.value + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let normalizedBG = normalize(bgValue) + let highLine = normalize(Storage.shared.highLine.value) + let lowLine = normalize(Storage.shared.lowLine.value) - if bgValue >= highLine { + if normalizedBG >= highLine { return .systemYellow - } else if bgValue <= lowLine { + } else if normalizedBG <= lowLine { return .systemRed } else { return .systemGreen diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..365c4ee20 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,6 +74,25 @@ final class UnitSettingsStore { } } + /// Normalizes a mg/dL glucose value to the precision of the current display unit. + /// + /// When the display unit is mmol/L, values are shown rounded to one decimal place. + /// This means two distinct mg/dL readings (e.g. 70 and 71) can both display as the + /// same mmol/L string (e.g. "3.9"). Comparing raw mg/dL values against a threshold + /// in that case produces a color that doesn't match the displayed number. + /// + /// Call this on both the reading *and* the threshold before doing a color comparison + /// so that the result is always consistent with what the user sees on screen. + func normalizeForColorComparison(_ mgdl: Double) -> Double { + switch glucoseUnit { + case .mgdL: + return mgdl + case .mmolL: + // Round to one decimal place, matching the display precision for mmol/L. + return GlucoseConversion.toDisplayedMmol(mgdl) + } + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e8eb43de3..5d97f9157 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -5,6 +5,7 @@ #if !targetEnvironment(macCatalyst) @preconcurrency import ActivityKit +import Combine import Foundation import os import UIKit @@ -42,6 +43,18 @@ final class LiveActivityManager { ) startPushToStartTokenObservation() startActivityUpdatesObservation() + + Publishers.Merge( + Storage.shared.lowLine.$value.map { _ in () }, + Storage.shared.highLine.$value.map { _ in () } + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleThresholdChange() + } + } + .store(in: &cancellables) } // MARK: - Push-to-start observation @@ -210,6 +223,44 @@ final class LiveActivityManager { } } + /// Pushes an immediate update when the custom low/high lines are edited. + /// TIR and TITR use fixed ranges, so the lines only affect LA colors in custom mode. + @MainActor + private func handleThresholdChange() { + guard UnitSettingsStore.shared.timeInRangeMode == .custom else { return } + + writeThresholds() + + guard Storage.shared.laEnabled.value, let activity = current else { return } + + let provider = StorageCurrentGlucoseStateProvider() + guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } + + GlucoseSnapshotStore.shared.save(snapshot) + + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: snapshot, + seq: nextSeq, + reason: "threshold-changed", + producedAt: Date(), + ) + let content = ActivityContent( + state: state, + staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), + relevanceScore: 100.0, + ) + + Task { + await activity.update(content) + LogManager.shared.log(category: .general, message: "[LA] threshold-changed flush sent seq=\(nextSeq)", isDebug: true) + if let token = pushToken { + await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) + } + } + } + @objc private func handleDidBecomeActive() { guard Storage.shared.laEnabled.value else { return } let appState = UIApplication.shared.applicationState.rawValue @@ -427,6 +478,7 @@ final class LiveActivityManager { /// and arrives within a few seconds of the first request. private static let pushToStartTokenRetryDelay: TimeInterval = 10 + private var cancellables = Set() private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 05d96f309..f85aaad68 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -269,7 +269,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher() + Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -283,6 +285,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { self?.updateBGGraphSettings() self?.updateBGGraph() LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") + self?.updateStats() } .store(in: &cancellables) @@ -830,9 +833,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { let latestBG = bgData[bgData.count - 1].sgv if Storage.shared.colorBGText.value { let thresholds = UnitSettingsStore.shared.effectiveThresholds() - if Double(latestBG) >= thresholds.high { + let normalize = UnitSettingsStore.shared.normalizeForColorComparison + let bgValue = normalize(Double(latestBG)) + if bgValue >= normalize(thresholds.high) { Observable.shared.bgTextColor.value = .yellow - } else if Double(latestBG) <= thresholds.low { + } else if bgValue <= normalize(thresholds.low) { Observable.shared.bgTextColor.value = .red } else { Observable.shared.bgTextColor.value = .green diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 177529a92..71b210ebc 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -773,36 +773,58 @@ private enum LAFormat { // MARK: - Threshold-driven colors private enum LAColors { + /// Which side of the thresholds a reading falls on, compared at display precision + /// so the color always matches the number shown (two mg/dL values can both show "3.9"). + private enum Band { case low, high, inRange } + + private static func band(for snapshot: GlucoseSnapshot, low: Double, high: Double) -> Band { + let mgdl = snapshot.glucose + let value: Double + let lowLimit: Double + let highLimit: Double + switch snapshot.unit { + case .mgdl: + (value, lowLimit, highLimit) = (mgdl, low, high) + case .mmol: + value = GlucoseConversion.toDisplayedMmol(mgdl) + lowLimit = GlucoseConversion.toDisplayedMmol(low) + highLimit = GlucoseConversion.toDisplayedMmol(high) + } + + if value <= lowLimit { return .low } + if value >= highLimit { return .high } + return .inRange + } + 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 { + switch band(for: snapshot, low: low, high: high) { + case .low: let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl >= high { + case .high: let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - } else { + case .inRange: 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 { + switch band(for: snapshot, low: t.low, high: t.high) { + case .low: return Color(uiColor: UIColor.systemRed) - } else if mgdl >= high { + case .high: return Color(uiColor: UIColor.systemOrange) - } else { + case .inRange: return Color(uiColor: UIColor.systemGreen) } } diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index dab205bfa..67def89a6 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -10,4 +10,9 @@ enum GlucoseConversion { static func toMmol(_ mgdl: Double) -> Double { mgdl * mgDlToMmolL } + + /// Converts mg/dL to mmol/L rounded to the one-decimal precision used for display. + static func toDisplayedMmol(_ mgdl: Double) -> Double { + (toMmol(mgdl) * 10).rounded() / 10 + } }