Skip to content
Merged
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
4 changes: 2 additions & 2 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions LoopFollow/Controllers/MainViewController+updateStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,16 @@ 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()
let values = bgData
.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,
Expand Down
62 changes: 40 additions & 22 deletions LoopFollow/Helpers/Localizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 25 additions & 17 deletions LoopFollow/Onboarding/Steps/UnitsStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Settings/SettingsMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
]
Expand Down
141 changes: 107 additions & 34 deletions LoopFollow/Settings/UnitsConfigurationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions LoopFollow/Stats/GRI/GRICalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Stats/TIR/TIRView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 1 addition & 9 deletions LoopFollow/Stats/TIR/TIRViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Loading