diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index f3c4c0d10..582f325eb 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -48,5 +48,38 @@ extension MainViewController { statsDisplayModel.pieRange = Double(stats.percentRange) statsDisplayModel.pieHigh = Double(stats.percentHigh) } + updateTIRBand() + } + + /// Today's (since midnight) range distribution for the Time in Range band. + /// Very low is below 54 mg/dL and very high above 250 mg/dL; low, in range + /// and high follow the Range Mode chosen in Settings (TIR, TITR or Custom). + func updateTIRBand() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + let startOfDay = Calendar.current.startOfDay(for: Date()).timeIntervalSince1970 + let values = bgData + .filter { $0.date >= startOfDay && $0.sgv > 0 } + .map { Double($0.sgv) } + + switch UnitSettingsStore.shared.timeInRangeMode { + case .tir: statsDisplayModel.bandTitle = "Time in Range" + case .titr: statsDisplayModel.bandTitle = "Time in Tight Range" + case .custom: statsDisplayModel.bandTitle = "Time in Range" + } + + let percentages = TIRCalculator.calculatePercentages( + readings: values, + veryLowThreshold: 54.0, + lowThreshold: thresholds.low, + highThreshold: thresholds.high, + veryHighThreshold: 250.0 + ) + + statsDisplayModel.bandVeryLowPct = percentages.veryLow + statsDisplayModel.bandLowPct = percentages.low + statsDisplayModel.bandInRangePct = percentages.inRange + statsDisplayModel.bandHighPct = percentages.high + statsDisplayModel.bandVeryHighPct = percentages.veryHigh + statsDisplayModel.bandHasData = !values.isEmpty } } diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift index 72e60560a..8f3f75d0b 100644 --- a/LoopFollow/Controllers/StatsDisplayModel.swift +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -15,4 +15,13 @@ class StatsDisplayModel: ObservableObject { @Published var pieLow: Double = 0 @Published var pieRange: Double = 0 @Published var pieHigh: Double = 0 + + // Time in Range band (today since midnight) + @Published var bandTitle: String = "Time in Range" + @Published var bandHasData: Bool = false + @Published var bandVeryLowPct: Double = 0 + @Published var bandLowPct: Double = 0 + @Published var bandInRangePct: Double = 0 + @Published var bandHighPct: Double = 0 + @Published var bandVeryHighPct: Double = 0 } diff --git a/LoopFollow/Controllers/TIRBandView.swift b/LoopFollow/Controllers/TIRBandView.swift new file mode 100644 index 000000000..06fbe4ffc --- /dev/null +++ b/LoopFollow/Controllers/TIRBandView.swift @@ -0,0 +1,99 @@ +// LoopFollow +// TIRBandView.swift + +import SwiftUI + +/// Time in Range band for the home screen: today's in-range +/// percentage above a segmented bar (very low / low / in range / high / very high). +/// The range follows the Range Mode in Settings (TIR, TITR or Custom). +/// Tapping opens the statistics screen. +struct TIRBandView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + private var percentText: String { + guard model.bandHasData else { return "-- %" } + return model.bandInRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + } + + private var segments: [(color: Color, fraction: CGFloat)] { + guard model.bandHasData else { return [(Color.secondary.opacity(0.3), 1)] } + return [ + (TIRGraphView.Band.veryLow.color, CGFloat(model.bandVeryLowPct / 100)), + (TIRGraphView.Band.low.color, CGFloat(model.bandLowPct / 100)), + (TIRGraphView.Band.inRange.color, CGFloat(model.bandInRangePct / 100)), + (TIRGraphView.Band.high.color, CGFloat(model.bandHighPct / 100)), + (TIRGraphView.Band.veryHigh.color, CGFloat(model.bandVeryHighPct / 100)), + ] + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(percentText) + .font(.title2) + .fontWeight(.bold) + .fontDesign(.rounded) + .foregroundStyle(.primary) + (Text(model.bandTitle).fontWeight(.semibold) + Text(" today")) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + DistributionBar(segments: segments) + .frame(height: 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } +} + +/// Horizontal bar of capsules, one per non-empty segment, sized by share. +/// Every non-empty segment is at least `minSegmentWidth` wide so a single +/// reading stays visible. +private struct DistributionBar: View { + let segments: [(color: Color, fraction: CGFloat)] + + private let spacing: CGFloat = 2 + private let minSegmentWidth: CGFloat = 4 + + var body: some View { + GeometryReader { geo in + let shown = segments.filter { $0.fraction > 0 } + let available = max(geo.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0) + let widths = segmentWidths(shown.map(\.fraction), available: available) + HStack(spacing: spacing) { + ForEach(Array(shown.enumerated()), id: \.offset) { index, segment in + Capsule() + .fill(segment.color) + .frame(width: widths[index]) + } + } + .frame(maxHeight: .infinity) + } + } + + private func segmentWidths(_ fractions: [CGFloat], available: CGFloat) -> [CGFloat] { + var widths = fractions.map { max($0 * available, minSegmentWidth) } + let excess = widths.reduce(0, +) - available + if excess > 0, let widest = widths.indices.max(by: { widths[$0] < widths[$1] }) { + widths[widest] = max(widths[widest] - excess, minSegmentWidth) + } + return widths + } +} diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..75608ae57 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -8,6 +8,7 @@ struct GeneralSettingsView: View { @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState @ObservedObject var showDisplayName = Storage.shared.showDisplayName @@ -45,6 +46,12 @@ struct GeneralSettingsView: View { } } Toggle("Display Stats", isOn: $showStats.value) + if showStats.value { + Picker("Stats Style", selection: $showTIRBand.value) { + Text("Statistics Box").tag(false) + Text("Time in Range Band").tag(true) + } + } Toggle("Display Small Graph", isOn: $showSmallGraph.value) Toggle("Color BG Text", isOn: $colorBGText.value) Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..7c0a875dd 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -158,6 +158,7 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Persistent Notification"), SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]), SettingsLeaf("Display Stats"), + SettingsLeaf("Stats Style", ["time in range band", "statistics box", "tir band", "progress bar"]), SettingsLeaf("Display Small Graph"), SettingsLeaf("Color BG Text"), SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]), diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..af814a5dd 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -91,11 +91,11 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], - veryLowThreshold: Double, - lowThreshold: Double, - highThreshold: Double, - veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) + static func calculatePercentages(readings: [Double], + veryLowThreshold: Double, + lowThreshold: Double, + highThreshold: Double, + veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { let total = Double(readings.count) guard total > 0 else { diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift index 03a84a14b..1eba0f767 100644 --- a/LoopFollow/Stats/TIR/TIRGraphView.swift +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -7,7 +7,7 @@ import SwiftUI struct TIRGraphView: View { let tirData: [TIRDataPoint] - private enum Band: String, CaseIterable, Plottable { + enum Band: String, CaseIterable, Plottable { case veryLow = "Very Low" case low = "Low" case inRange = "In Range" diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..5e8f254f8 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -70,6 +70,8 @@ class Storage { var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) + /// Show the home screen stats as a Time in Range band instead of the statistics box. + var showTIRBand = StorageValue(key: "showTIRBand", defaultValue: false) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) var showSmallGraph = StorageValue(key: "showSmallGraph", defaultValue: true) var screenlockSwitchState = StorageValue(key: "screenlockSwitchState", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 599120f7d..5be5c0a2f 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -13,6 +13,7 @@ struct MainHomeView: View { @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject var url = Storage.shared.url @@ -64,7 +65,11 @@ struct MainHomeView: View { // Statistics if showStats.value { - StatsDisplayView(model: statsModel, onTap: onStatsTap) + if showTIRBand.value { + TIRBandView(model: statsModel, onTap: onStatsTap) + } else { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } } } .padding(8) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..507119d1c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -282,6 +282,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + self?.updateStats() } .store(in: &cancellables)