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/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 1f10d25fb..b1a87710c 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -66,8 +66,8 @@ extension MainViewController { /// Range distribution for the Time in Range band over the same period as the /// statistics box (last 24 hours, or today since midnight). - /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR - /// or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or + /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR, + /// TING or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or /// the range's own bounds when a custom range extends past them. func updateTIRBand(periodStart: TimeInterval) { let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() @@ -75,7 +75,7 @@ extension MainViewController { .filter { $0.date >= periodStart && $0.sgv > 0 } .map { Double($0.sgv) } - statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeTitle + statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeMode.title let percentages = TIRCalculator.calculatePercentages( readings: values, diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index e36c08153..3d32a51da 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -18,10 +18,41 @@ 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 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" + } + } + + /// 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 .ting: return (GlucoseThresholds.tingLow, GlucoseThresholds.tingHigh) + 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,33 +95,20 @@ 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) } /// Returns the very low, low, high and very high thresholds (in mg/dL) for - /// the current range mode. Very low is 54 and very high 250, or the range's - /// own bounds when a custom range extends past them. + /// the current range mode. Very low and very high come from `GlucoseThresholds`, + /// or the range's own bounds when a custom range extends past them. func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) { let range = effectiveThresholds() - return (min(54.0, range.low), range.low, range.high, max(250.0, range.high)) - } - - var timeInRangeTitle: String { - switch timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } + return ( + min(GlucoseThresholds.veryLow, range.low), + range.low, + range.high, + max(GlucoseThresholds.veryHigh, range.high) + ) } 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/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1f1b28535..828d36058 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -194,7 +194,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/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/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index c322cf59b..a31aa5d5e 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -13,7 +13,7 @@ struct TIRView: View { ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 12) { HStack { - Text(UnitSettingsStore.shared.timeInRangeTitle) + Text(UnitSettingsStore.shared.timeInRangeMode.title) .font(.caption) .foregroundColor(.secondary) Spacer() 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..d4e5d6bc4 --- /dev/null +++ b/Shared/GlucoseThresholds.swift @@ -0,0 +1,21 @@ +// 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 + /// Extended Tight Range (TING, Time in Normoglycemia). + static let tingLow = 63.0 + static let tingHigh = 140.0 +}