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
7 changes: 7 additions & 0 deletions Loop/Models/LoopConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ enum LoopConstants {

static let statusChartMinimumHistoryDisplay: TimeInterval = .hours(1)

/// Chart width the time window options are sized for: a portrait iPhone.
static let statusChartReferenceWidth: CGFloat = 360

static let statusChartMaximumWidthScale: CGFloat = 2

static let chartMinimumWidthPerTimeLabel: CGFloat = 44

static let glucoseChartDefaultDisplayBound =
LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 175)

Expand Down
51 changes: 44 additions & 7 deletions Loop/View Controllers/StatusTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,23 @@ final class StatusTableViewController: LoopChartsTableViewController {
tableView.register(InsulinSuspendedTableViewCell.nib(), forCellReuseIdentifier: InsulinSuspendedTableViewCell.className)
tableView.register(RecentGlucoseTableViewCell.nib(), forCellReuseIdentifier: RecentGlucoseTableViewCell.className)

if FeatureFlags.predictedGlucoseChartClampEnabled {
statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped
} else {
statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBound
}
applyDisplayPreferences()

registerPumpManager()
registerCGMManager()

let notificationCenter = NotificationCenter.default

notificationObservers += [
notificationCenter.addObserver(forName: UserDefaults.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self, self.displayPreferencesSnapshot != self.currentDisplayPreferences else { return }
self.applyDisplayPreferences()
self.refreshContext = RefreshContext.all
self.tableView.reloadSections(IndexSet(integer: Section.charts.rawValue), with: .none)
Task { await self.reloadData() }
}
},
notificationCenter.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { note in
let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopUpdateContext.RawValue
let context = LoopUpdateContext(rawValue: rawContext)
Expand Down Expand Up @@ -439,13 +444,45 @@ final class StatusTableViewController: LoopChartsTableViewController {
deviceManager.cgmManager == nil || deviceManager.cgmManager?.isInoperable == true || deviceManager.cgmManager?.inSignalLoss == true || deviceManager.pumpManager == nil || deviceManager.pumpManager?.isInoperable == true || deviceManager.pumpManager?.inSignalLoss == true || deviceManager.hasBluetoothIssue
}

private var displayPreferencesSnapshot: [AnyHashable] = []

private var currentDisplayPreferences: [AnyHashable] {
let defaults = UserDefaults.standard
return [
defaults.bool(forKey: DisplayPreference.nowLine),
defaults.bool(forKey: DisplayPreference.fixedGlucoseScale),
defaults.integer(forKey: DisplayPreference.historyHours)
]
}

private func applyDisplayPreferences() {
displayPreferencesSnapshot = currentDisplayPreferences
let defaults = UserDefaults.standard
ChartDisplayOptions.showsNowLine = defaults.bool(forKey: DisplayPreference.nowLine)
statusCharts.glucose.usesFixedScale = defaults.bool(forKey: DisplayPreference.fixedGlucoseScale)
if FeatureFlags.predictedGlucoseChartClampEnabled {
statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped
} else {
statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBound
}
StatusChartsManager.ChartIndex.allCases.forEach { statusCharts.invalidateChart(atIndex: $0.rawValue) }
}

private func updateChartDateRange() {
// How far back should we show data? Use the screen size as a guide.
let availableWidth = (refreshContext.newSize ?? tableView.bounds.size).width - charts.fixedHorizontalMargin

let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour))
var totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour))
let futureHours = ceil(doseStore.longestEffectDuration.hours)
let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours)
var historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours)
let preferredHistoryHours = UserDefaults.standard.integer(forKey: DisplayPreference.historyHours)
if preferredHistoryHours > 0 {
let forecastHours = preferredHistoryHours <= 3 ? 3 : futureHours
let widthScale = min(LoopConstants.statusChartMaximumWidthScale, max(1, availableWidth / LoopConstants.statusChartReferenceWidth))
totalHours = (Double(preferredHistoryHours) + forecastHours) * Double(widthScale)
historyHours = totalHours - forecastHours
}
charts.labelStepHours = max(1, Int(ceil(totalHours * Double(LoopConstants.chartMinimumWidthPerTimeLabel / availableWidth))))

let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours))
let chartStartDate = Calendar.current.nextDate(after: date, matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? date
Expand Down
65 changes: 65 additions & 0 deletions Loop/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,18 @@ extension SettingsView {
)
.accessibilityIdentifier("settingsViewLiveActivity")
}
NavigationLink(destination: DisplaySettingsView()) {
LargeButton(
action: {},
includeArrow: false,
imageView: Image(systemName: "chart.line.uptrend.xyaxis")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30),
label: NSLocalizedString("Display", comment: "Display settings button text"),
descriptiveText: NSLocalizedString("Chart scale, time window and now line", comment: "Display settings descriptive text")
)
}
}
}

Expand Down Expand Up @@ -820,3 +832,56 @@ public struct SettingsView_Previews: PreviewProvider {
}
}
}

enum DisplayPreference {
static let nowLine = "DisplayNowLine"
static let fixedGlucoseScale = "DisplayFixedGlucoseScale"
static let historyHours = "DisplayHistoryHours"
}

struct DisplaySettingsView: View {
@AppStorage(DisplayPreference.nowLine) private var nowLine = false
@AppStorage(DisplayPreference.fixedGlucoseScale) private var fixedGlucoseScale = false
@AppStorage(DisplayPreference.historyHours) private var historyHours = 0
@EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference

private var fixedScaleRange: String {
let values = GlucoseChart.fixedScaleAxisValues(for: displayGlucosePreference.unit)
let formatter = displayGlucosePreference.formatter.numberFormatter
return "\(formatter.string(from: values.first! as NSNumber) ?? "")–\(formatter.string(from: values.last! as NSNumber) ?? "")"
}

var body: some View {
List {
Section(footer: Text("A vertical line marking the current time on each chart.")) {
Toggle("Now Line", isOn: $nowLine)
}
Section(header: Text("Glucose Scale"), footer: Text("Fixed keeps the glucose chart at \(fixedScaleRange) \(displayGlucosePreference.unit.localizedShortUnitString) so every glance uses the same scale. Auto zooms to fit recent and forecast glucose.")) {
Picker("Glucose Scale", selection: $fixedGlucoseScale) {
Text("Auto").tag(false)
Text("Fixed \(fixedScaleRange)").tag(true)
}
.pickerStyle(.segmented)
}
Section(header: Text("Time Window"), footer: Text("The full forecast covers your insulin action time.")) {
Picker("Time Window", selection: $historyHours) {
timeWindowRow("Full Forecast", detail: "As much history as fits").tag(0)
timeWindowRow("3 Hours", detail: "3 h back, 3 h ahead").tag(3)
timeWindowRow("6 Hours", detail: "6 h back, full forecast").tag(6)
timeWindowRow("12 Hours", detail: "12 h back, full forecast").tag(12)
}
.pickerStyle(.inline)
.labelsHidden()
}
}
.insetGroupedListStyle()
.navigationTitle("Display")
}

private func timeWindowRow(_ title: LocalizedStringKey, detail: LocalizedStringKey) -> some View {
VStack(alignment: .leading) {
Text(title)
Text(detail).font(.footnote).foregroundColor(.secondary)
}
}
}