Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions LoopFollow/Contact/ContactColorMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions LoopFollow/Helpers/Localizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#if !targetEnvironment(macCatalyst)

@preconcurrency import ActivityKit
import Combine
import Foundation
import os
import UIKit
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<AnyCancellable>()
private(set) var current: Activity<GlucoseLiveActivityAttributes>?
private var stateObserverTask: Task<Void, Never>?
private var updateTask: Task<Void, Never>?
Expand Down
11 changes: 8 additions & 3 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -283,6 +285,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
self?.updateBGGraphSettings()
self?.updateBGGraph()
LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed")
self?.updateStats()
}
.store(in: &cancellables)

Expand Down Expand Up @@ -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
Expand Down
40 changes: 31 additions & 9 deletions LoopFollowLAExtension/LoopFollowLiveActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
5 changes: 5 additions & 0 deletions Shared/GlucoseConversion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading