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
33 changes: 33 additions & 0 deletions LoopFollow/Controllers/MainViewController+updateStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
9 changes: 9 additions & 0 deletions LoopFollow/Controllers/StatsDisplayModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
99 changes: 99 additions & 0 deletions LoopFollow/Controllers/TIRBandView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 7 additions & 0 deletions LoopFollow/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Settings/SettingsMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
10 changes: 5 additions & 5 deletions LoopFollow/Stats/TIR/TIRCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Stats/TIR/TIRGraphView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions LoopFollow/Storage/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class Storage {
var colorBGText = StorageValue<Bool>(key: "colorBGText", defaultValue: true)
var appearanceMode = StorageValue<AppearanceMode>(key: "appearanceMode", defaultValue: .dark)
var showStats = StorageValue<Bool>(key: "showStats", defaultValue: true)
/// Show the home screen stats as a Time in Range band instead of the statistics box.
var showTIRBand = StorageValue<Bool>(key: "showTIRBand", defaultValue: false)
var useIFCC = StorageValue<Bool>(key: "useIFCC", defaultValue: false)
var showSmallGraph = StorageValue<Bool>(key: "showSmallGraph", defaultValue: true)
var screenlockSwitchState = StorageValue<Bool>(key: "screenlockSwitchState", defaultValue: true)
Expand Down
7 changes: 6 additions & 1 deletion LoopFollow/ViewControllers/MainHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/ViewControllers/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
.sink { [weak self] _ in
self?.updateBGGraphSettings()
self?.updateBGGraph()
self?.updateStats()
}
.store(in: &cancellables)

Expand Down