diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index 5a78c4e8f..42b38b273 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -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) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index b41971409..70d95eebd 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -96,11 +96,7 @@ 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() @@ -108,6 +104,15 @@ final class StatusTableViewController: LoopChartsTableViewController { 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) @@ -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 diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 1faefbc5e..00755b87d 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -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") + ) + } } } @@ -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) + } + } +}