From 1ed1b201ca5c6457d58ae72fca8b6e03f02edf33 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 26 Oct 2026 22:29:19 +0100 Subject: [PATCH 1/2] Define glucose range cut-offs in one place and pick the range mode from a full-width list The 54/70/140/180/250 mg/dL cut-offs were repeated across the stats, GRI, the settings footer and the Live Activity. They now live in GlucoseThresholds in Shared, so the app, Live Activity and watch targets all see the same values. Each TimeInRangeDisplayMode carries its title and fixed thresholds, and the range settings, stats title and stats card toggle are built from allCases, so a new mode is its cut-offs plus one case. The segmented TIR/TITR/Custom picker and its footer are replaced by a row showing the selected mode's full name and cut-offs, which opens a list with one line per mode. Choosing Custom Range stays on the list and shows the Low and High pickers there. The onboarding statistics page gets a navigation stack so the list can open from it. --- LoopFollow/Charts/BGChartModel.swift | 4 +- LoopFollow/Helpers/Localizer.swift | 37 ++++- .../Onboarding/Steps/UnitsStepView.swift | 42 +++--- .../Settings/UnitsConfigurationView.swift | 141 +++++++++++++----- LoopFollow/Stats/GRI/GRICalculator.swift | 8 +- LoopFollow/Stats/TIR/TIRCalculator.swift | 4 +- LoopFollow/Stats/TIR/TIRView.swift | 13 +- LoopFollow/Stats/TIR/TIRViewModel.swift | 10 +- .../LoopFollowLiveActivity.swift | 2 +- Shared/GlucoseThresholds.swift | 18 +++ 10 files changed, 192 insertions(+), 87 deletions(-) create mode 100644 Shared/GlucoseThresholds.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a7089d6dc..525fcfcb2 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -415,8 +415,8 @@ final class BGChartModel: ObservableObject { let maxBGValue = Double(vc.calculateMaxBgGraphValue()) maxBG = max(maxBGValue, Storage.shared.minBGScale.value) - // Same thresholds the stats and main header use: fixed 70–180 / 70–140 - // for the TIR/TITR range modes, the user's lines for custom mode. + // Same thresholds the stats and main header use: the range mode's fixed + // cut-offs, or the user's lines for custom mode. let thresholds = UnitSettingsStore.shared.effectiveThresholds() lowLine = thresholds.low highLine = thresholds.high diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..fb2d34c81 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -18,10 +18,38 @@ enum GlucoseDisplayUnit: String, Codable, CaseIterable { } } +/// The selectable range modes. To add one, define its low/high in `GlucoseThresholds`, +/// then add a case here with a title and `fixedThresholds` pointing at those constants; +/// the settings range list, stats card and toggle pick it up from `allCases`. +/// The raw value is persisted, so don't change existing ones. enum TimeInRangeDisplayMode: String, Codable, CaseIterable { case tir = "TIR" case titr = "TITR" case custom = "Custom" + + var title: String { + switch self { + case .tir: return "Time in Range" + case .titr: return "Time in Tight Range" + case .custom: return "Custom Range" + } + } + + /// Low/high cut-offs in mg/dL from `GlucoseThresholds`, or nil when the user's own low/high lines apply. + var fixedThresholds: (low: Double, high: Double)? { + switch self { + case .tir: return (GlucoseThresholds.tirLow, GlucoseThresholds.tirHigh) + case .titr: return (GlucoseThresholds.titrLow, GlucoseThresholds.titrHigh) + case .custom: return nil + } + } + + /// The mode after this one in `allCases`, wrapping around. + var next: TimeInRangeDisplayMode { + let all = Self.allCases + let index = all.firstIndex(of: self)! + return all[(index + 1) % all.count] + } } enum GlycemicMetricMode: String, Codable, CaseIterable { @@ -64,14 +92,7 @@ final class UnitSettingsStore { /// Returns the effective low/high thresholds (in mg/dL) for the current range mode. func effectiveThresholds() -> (low: Double, high: Double) { - switch timeInRangeMode { - case .tir: - return (70.0, 180.0) - case .titr: - return (70.0, 140.0) - case .custom: - return (Storage.shared.lowLine.value, Storage.shared.highLine.value) - } + timeInRangeMode.fixedThresholds ?? (Storage.shared.lowLine.value, Storage.shared.highLine.value) } var glycemicMetricMode: GlycemicMetricMode { diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift index 25138e611..00b4d333a 100644 --- a/LoopFollow/Onboarding/Steps/UnitsStepView.swift +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -19,23 +19,10 @@ struct UnitsStepView: View { var body: some View { VStack(spacing: 0) { - Form { - switch page { - case .units: - header( - systemImage: "ruler", - title: "Units", - subtitle: "Choose how glucose values are displayed. You can change this later in Settings." - ) - UnitsConfigurationView(sections: .units) - case .statistics: - header( - systemImage: "chart.bar.xaxis", - title: "Statistics", - subtitle: "Choose how LoopFollow's statistics are measured and displayed." - ) - UnitsConfigurationView(sections: .statistics) - } + // Lets the range mode row push its list; the bar only shows on that list. + NavigationStack { + form + .toolbar(.hidden, for: .navigationBar) } footer @@ -44,6 +31,27 @@ struct UnitsStepView: View { .onChange(of: page) { _ in reportProgress() } } + private var form: some View { + Form { + switch page { + case .units: + header( + systemImage: "ruler", + title: "Units", + subtitle: "Choose how glucose values are displayed. You can change this later in Settings." + ) + UnitsConfigurationView(sections: .units) + case .statistics: + header( + systemImage: "chart.bar.xaxis", + title: "Statistics", + subtitle: "Choose how LoopFollow's statistics are measured and displayed." + ) + UnitsConfigurationView(sections: .statistics) + } + } + } + private func reportProgress() { onboarding.phaseProgress = .init(page: page.rawValue, count: Page.allCases.count) } diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 2124077c7..5eb4cf6f8 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -30,13 +30,19 @@ struct UnitsConfigurationView: View { @State private var highValue = Storage.shared.highLine.value /// Formats a mg/dL threshold pair in the currently selected glucose unit, - /// e.g. "70–180 mg/dL" or "3.9–10.0 mmol/L". + /// e.g. "70–180" or "3.9–10.0". private func rangeBounds(_ lowMgdl: Double, _ highMgdl: Double) -> String { let factor = glucoseUnit == .mmolL ? GlucoseConversion.mgDlToMmolL : 1.0 let digits = glucoseUnit.fractionDigits let low = Localizer.formatToLocalizedString(lowMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) let high = Localizer.formatToLocalizedString(highMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) - return "\(low)–\(high) \(glucoseUnit.rawValue)" + return "\(low)–\(high)" + } + + /// The mode's range in the selected unit; custom uses the user's own low and high. + private func rangeBounds(for mode: TimeInRangeDisplayMode) -> String { + let thresholds = mode.fixedThresholds ?? (lowValue, highValue) + return rangeBounds(thresholds.low, thresholds.high) } var body: some View { @@ -68,43 +74,22 @@ struct UnitsConfigurationView: View { private var statisticsSections: some View { Group { Section { - Picker("Range Mode", selection: $rangeMode) { - Text("TIR").tag(TimeInRangeDisplayMode.tir) - Text("TITR").tag(TimeInRangeDisplayMode.titr) - Text("Custom").tag(TimeInRangeDisplayMode.custom) - } - .pickerStyle(.segmented) - .onChange(of: rangeMode) { newValue in - UnitSettingsStore.shared.timeInRangeMode = newValue - Observable.shared.chartSettingsChanged.value = true - } - - if rangeMode == .custom { - BGPicker( - title: "Low", - range: 40 ... 120, - value: $lowValue + NavigationLink { + RangeModeList( + selection: $rangeMode, + lowValue: $lowValue, + highValue: $highValue, + glucoseUnit: glucoseUnit, + bounds: rangeBounds(for:) ) - .id(glucoseUnit) - .onChange(of: lowValue) { newValue in - Storage.shared.lowLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } - BGPicker( - title: "High", - range: 120 ... 400, - value: $highValue - ) - .id(glucoseUnit) - .onChange(of: highValue) { newValue in - Storage.shared.highLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } + } label: { + RangeModeRow(mode: rangeMode, bounds: rangeBounds(for: rangeMode)) } + .accessibilityLabel("Range Mode, \(rangeMode.title)") } header: { Text("Range") } footer: { - Text("TIR — Time in Range, the share of readings within \(rangeBounds(70, 180)).\nTITR — Time in Tight Range, within \(rangeBounds(70, 140)).\nCustom — set your own low and high.") + Text("Cut-offs in \(glucoseUnit.rawValue).") } Section { @@ -149,6 +134,94 @@ struct UnitsConfigurationView: View { } } +/// A range mode's name with its cut-offs trailing, on one line. +private struct RangeModeRow: View { + let mode: TimeInRangeDisplayMode + let bounds: String + + var body: some View { + HStack { + Text(mode.title) + .foregroundStyle(Color.primary) + Spacer() + // Color.secondary, not .secondary, which inside a Button is a shade of the tint. + Text(bounds) + .foregroundStyle(Color.secondary) + .monospacedDigit() + } + .lineLimit(1) + } +} + +/// Full-width list of range modes, so names and cut-offs fit on one line. +/// Picking a fixed mode goes back; picking custom stays to set its low and high. +private struct RangeModeList: View { + @Binding var selection: TimeInRangeDisplayMode + @Binding var lowValue: Double + @Binding var highValue: Double + let glucoseUnit: GlucoseDisplayUnit + let bounds: (TimeInRangeDisplayMode) -> String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + ForEach(TimeInRangeDisplayMode.allCases, id: \.self) { mode in + Button { + selection = mode + if mode != .custom { + dismiss() + } + } label: { + HStack { + RangeModeRow(mode: mode, bounds: bounds(mode)) + // Always laid out so the cut-offs line up whichever row is selected. + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .opacity(selection == mode ? 1 : 0) + } + } + .accessibilityAddTraits(selection == mode ? .isSelected : []) + } + } footer: { + Text("Cut-offs in \(glucoseUnit.rawValue).") + } + + if selection == .custom { + Section("Custom Range") { + BGPicker( + title: "Low", + range: 40 ... 120, + value: $lowValue + ) + .id(glucoseUnit) + BGPicker( + title: "High", + range: 120 ... 400, + value: $highValue + ) + .id(glucoseUnit) + } + } + } + .onChange(of: selection) { newValue in + UnitSettingsStore.shared.timeInRangeMode = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: lowValue) { newValue in + Storage.shared.lowLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: highValue) { newValue in + Storage.shared.highLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .navigationTitle("Range") + .navigationBarTitleDisplayMode(.inline) + } +} + /// Standalone page for units configuration during onboarding. /// Shows a checkmark button in the toolbar to complete setup. struct UnitsOnboardingView: View { diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index 1d84e17cc..fd170f55b 100644 --- a/LoopFollow/Stats/GRI/GRICalculator.swift +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -15,10 +15,10 @@ class GRICalculator { static func calculate(bgData: [ShareGlucoseData]) -> GRICalculationResult { guard !bgData.isEmpty else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) } - let vLowThreshold = 54.0 - let lowThreshold = 70.0 - let highThreshold = 180.0 - let vHighThreshold = 250.0 + let vLowThreshold = GlucoseThresholds.veryLow + let lowThreshold = GlucoseThresholds.tirLow + let highThreshold = GlucoseThresholds.tirHigh + let vHighThreshold = GlucoseThresholds.veryHigh var vLowCount = 0 var lowCount = 0 diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..e9b746f56 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -8,10 +8,10 @@ class TIRCalculator { guard !bgData.isEmpty else { return [] } let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let veryLowThreshold = 54.0 + let veryLowThreshold = GlucoseThresholds.veryLow let lowThreshold = thresholds.low let highThreshold = thresholds.high - let veryHighThreshold = 250.0 + let veryHighThreshold = GlucoseThresholds.veryHigh var periodData: [TIRPeriod: [Double]] = [:] let calendar = dateTimeUtils.displayCalendar() diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index 8ff66e273..ea22c969f 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -87,14 +87,7 @@ struct TIRView: View { } private var tirTitle: String { - switch UnitSettingsStore.shared.timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } + UnitSettingsStore.shared.timeInRangeMode.title } private func formatRange(_: Double) -> String { @@ -116,11 +109,11 @@ struct TIRView: View { } private var veryLowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(54.0) + UnitSettingsStore.shared.convertMgdlToDisplay(GlucoseThresholds.veryLow) } private var veryHighThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(250.0) + UnitSettingsStore.shared.convertMgdlToDisplay(GlucoseThresholds.veryHigh) } private var veryLowCutoffText: String { diff --git a/LoopFollow/Stats/TIR/TIRViewModel.swift b/LoopFollow/Stats/TIR/TIRViewModel.swift index 6f04bddec..c3b932b28 100644 --- a/LoopFollow/Stats/TIR/TIRViewModel.swift +++ b/LoopFollow/Stats/TIR/TIRViewModel.swift @@ -20,15 +20,7 @@ class TIRViewModel: ObservableObject { } func toggleTIRMode() { - let mode = UnitSettingsStore.shared.timeInRangeMode - switch mode { - case .tir: - UnitSettingsStore.shared.timeInRangeMode = .titr - case .titr: - UnitSettingsStore.shared.timeInRangeMode = .custom - case .custom: - UnitSettingsStore.shared.timeInRangeMode = .tir - } + UnitSettingsStore.shared.timeInRangeMode = UnitSettingsStore.shared.timeInRangeMode.next calculateTIR() } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9f6ca7c94..a9018ca94 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -816,7 +816,7 @@ private enum LAColors { let high = t.high if mgdl <= low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - GlucoseThresholds.veryLow, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) } else if mgdl >= high { diff --git a/Shared/GlucoseThresholds.swift b/Shared/GlucoseThresholds.swift new file mode 100644 index 000000000..128996654 --- /dev/null +++ b/Shared/GlucoseThresholds.swift @@ -0,0 +1,18 @@ +// LoopFollow +// GlucoseThresholds.swift + +import Foundation + +/// Fixed glucose cut-offs (mg/dL) shared by the stats, range modes and Live Activity. +/// Range modes read their low/high from here via `TimeInRangeDisplayMode.fixedThresholds`, +/// so a new mode's cut-offs go here first. +enum GlucoseThresholds { + static let veryLow = 54.0 + static let veryHigh = 250.0 + /// Time in Range, also used by GRI regardless of the selected range mode. + static let tirLow = 70.0 + static let tirHigh = 180.0 + /// Time in Tight Range. + static let titrLow = 70.0 + static let titrHigh = 140.0 +} From 08ff3d5a609733f0813c9a993addb5b723954b5e Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 26 Oct 2026 22:30:14 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Add=20Extended=20Tight=20Range=20(TING,=206?= =?UTF-8?q?3=E2=80=93140=20mg/dL)=20as=20a=20range=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TING is the tight range with its lower cut-off moved down from 70 to 63 mg/dL, also known as Time in Normoglycemia. It is shown as Extended Tight Range, sits between Time in Tight Range and Custom Range in the range list and the stats card toggle, and its cut-offs live in GlucoseThresholds next to TIR and TITR. The settings search finds Range Mode for "ting" and "extended". --- LoopFollow/Helpers/Localizer.swift | 3 +++ LoopFollow/Settings/SettingsMenuView.swift | 2 +- Shared/GlucoseThresholds.swift | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index fb2d34c81..1cd7bd2a3 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -25,12 +25,14 @@ enum GlucoseDisplayUnit: String, Codable, CaseIterable { enum TimeInRangeDisplayMode: String, Codable, CaseIterable { case tir = "TIR" case titr = "TITR" + case ting = "TING" case custom = "Custom" var title: String { switch self { case .tir: return "Time in Range" case .titr: return "Time in Tight Range" + case .ting: return "Extended Tight Range" case .custom: return "Custom Range" } } @@ -40,6 +42,7 @@ enum TimeInRangeDisplayMode: String, Codable, CaseIterable { switch self { case .tir: return (GlucoseThresholds.tirLow, GlucoseThresholds.tirHigh) case .titr: return (GlucoseThresholds.titrLow, GlucoseThresholds.titrHigh) + case .ting: return (GlucoseThresholds.tingLow, GlucoseThresholds.tingHigh) case .custom: return nil } } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..b1ca5c9da 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -192,7 +192,7 @@ enum SettingsRoute: Hashable, Identifiable { + InfoType.allCases.map { SettingsLeaf($0.name) } case .units: return [ SettingsLeaf("Glucose Unit"), - SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]), + SettingsLeaf("Range Mode", ["tir", "titr", "ting", "time in range", "extended"]), SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]), SettingsLeaf("Variability", ["standard deviation", "cv"]), ] diff --git a/Shared/GlucoseThresholds.swift b/Shared/GlucoseThresholds.swift index 128996654..d4e5d6bc4 100644 --- a/Shared/GlucoseThresholds.swift +++ b/Shared/GlucoseThresholds.swift @@ -15,4 +15,7 @@ enum GlucoseThresholds { /// Time in Tight Range. static let titrLow = 70.0 static let titrHigh = 140.0 + /// Extended Tight Range (TING, Time in Normoglycemia). + static let tingLow = 63.0 + static let tingHigh = 140.0 }