From 5591c1b9ce1e1c0e9abba886f582f98d55d3341a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 7 Jul 2026 20:11:41 +0200 Subject: [PATCH 1/2] Add color thresholds to the Info Display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Individual info rows (IOB, COB, battery, pump battery, TDD, rec bolus, carbs, and sensor/cannula/insulin ages) can now turn yellow or red when their value crosses user-set thresholds — a visual cue only, with no alarm. The concerning direction is fixed per metric (battery colors when low, ages when high) and in-range values read green. Replaces the parallel infoSort/infoVisible arrays with a single ordered [InfoDisplayItem] store (migrated via migrateStep10), and reworks the Info Display settings into drill-in rows that reorder and open a per-row detail at the same time. --- LoopFollow.xcodeproj/project.pbxproj | 8 ++ LoopFollow/Controllers/Nightscout/CAge.swift | 2 +- .../Controllers/Nightscout/DeviceStatus.swift | 4 +- .../Nightscout/DeviceStatusLoop.swift | 2 +- LoopFollow/Controllers/Nightscout/IAge.swift | 2 +- LoopFollow/Controllers/Nightscout/SAge.swift | 2 +- .../Nightscout/Treatments/Carbs.swift | 2 +- .../Treatments/InsulinCartridgeChange.swift | 2 +- .../InfoDisplaySettingsView.swift | 53 ++++++++--- .../InfoDisplaySettingsViewModel.swift | 33 ++++--- .../InfoRowSettingsView.swift | 93 +++++++++++++++++++ LoopFollow/InfoTable/InfoData.swift | 6 +- LoopFollow/InfoTable/InfoDisplayItem.swift | 56 +++++++++++ LoopFollow/InfoTable/InfoManager.swift | 19 ++-- LoopFollow/InfoTable/InfoTableView.swift | 17 +++- LoopFollow/InfoTable/InfoType.swift | 41 +++++++- LoopFollow/Storage/Storage+Migrate.swift | 45 ++++++++- LoopFollow/Storage/Storage.swift | 13 ++- .../ViewControllers/MainViewController.swift | 42 ++++----- Tests/InfoColoringTests.swift | 47 ++++++++++ 20 files changed, 409 insertions(+), 80 deletions(-) create mode 100644 LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift create mode 100644 LoopFollow/InfoTable/InfoDisplayItem.swift create mode 100644 Tests/InfoColoringTests.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index b162633e0..ef78f0f10 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -100,6 +100,8 @@ DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; }; DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; }; DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; }; + DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000A0001 /* InfoDisplayItem.swift */; }; + DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000B0001 /* InfoRowSettingsView.swift */; }; DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; }; DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; }; DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; }; @@ -565,6 +567,8 @@ DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = ""; }; DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = ""; }; DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = ""; }; + DDA0000012341234000A0001 /* InfoDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplayItem.swift; sourceTree = ""; }; + DDA0000012341234000B0001 /* InfoRowSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRowSettingsView.swift; sourceTree = ""; }; DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = ""; }; DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = ""; }; DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = ""; }; @@ -1108,6 +1112,7 @@ DD13BC742C3FD6200062313B /* InfoType.swift */, DD13BC762C3FD64E0062313B /* InfoData.swift */, DD13BC782C3FE63A0062313B /* InfoManager.swift */, + DDA0000012341234000A0001 /* InfoDisplayItem.swift */, DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */, DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */, ); @@ -1190,6 +1195,7 @@ children = ( DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */, DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */, + DDA0000012341234000B0001 /* InfoRowSettingsView.swift */, ); path = InfoDisplaySettings; sourceTree = ""; @@ -2263,6 +2269,8 @@ DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */, DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */, DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */, + DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */, + DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */, DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */, DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */, DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */, diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index ed996fb1a..7e20bfade 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -51,7 +51,7 @@ extension MainViewController { formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale if let formattedDuration = formatter.string(from: secondsAgo) { - infoManager.updateInfoData(type: .cage, value: formattedDuration) + infoManager.updateInfoData(type: .cage, value: formattedDuration, numericValue: secondsAgo / 86400) } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index d2d6920d8..065c9f5a9 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -126,7 +126,7 @@ extension MainViewController { if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject], let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double { - infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%") + infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent) Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent } @@ -139,7 +139,7 @@ extension MainViewController { } else { batteryText = String(format: "%.0f", upbat) + "%" } - infoManager.updateInfoData(type: .battery, value: batteryText) + infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat) Observable.shared.deviceBatteryLevel.value = upbat let timestamp = uploader["timestamp"] as? Date ?? Date() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 771fdc68d..9e7fc8e25 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -102,7 +102,7 @@ extension MainViewController { } if let recBolus = lastLoopRecord["recommendedBolus"] as? Double { let formattedRecBolus = String(format: "%.2fU", recBolus) - infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus) + infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus, numericValue: recBolus) Observable.shared.deviceRecBolus.value = recBolus } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 50e9bd592..71909cc49 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -55,7 +55,7 @@ extension MainViewController { formatter.zeroFormattingBehavior = [.pad] if let formattedDuration = formatter.string(from: secondsAgo) { - infoManager.updateInfoData(type: .iage, value: formattedDuration) + infoManager.updateInfoData(type: .iage, value: formattedDuration, numericValue: secondsAgo / 86400) } } } diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index f5de50a78..580d39f1a 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -72,7 +72,7 @@ extension MainViewController { formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale if let formattedDuration = formatter.string(from: secondsAgo) { - infoManager.updateInfoData(type: .sage, value: formattedDuration) + infoManager.updateInfoData(type: .sage, value: formattedDuration, numericValue: secondsAgo / 86400) } } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 5d75adb2d..d5041ff13 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -81,7 +81,7 @@ extension MainViewController { } let resultString = String(format: "%.0f", totalCarbs) - infoManager.updateInfoData(type: .carbsToday, value: resultString) + infoManager.updateInfoData(type: .carbsToday, value: resultString, numericValue: totalCarbs) Storage.shared.lastCarbsToday.value = totalCarbs } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift b/LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift index d160bd1c9..2bff1c691 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/InsulinCartridgeChange.swift @@ -9,7 +9,7 @@ extension MainViewController { updateIage(data: entries) } else if let iage = currentIage { updateIage(data: [iage]) - } else if Storage.shared.infoVisible.value[InfoType.iage.rawValue] { + } else if Storage.shared.infoDisplayItems.value.isVisible(.iage) { webLoadNSIage() } } diff --git a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift index d3b589f5d..c43a195c4 100644 --- a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift +++ b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift @@ -5,6 +5,7 @@ import SwiftUI struct InfoDisplaySettingsView: View { @ObservedObject var viewModel: InfoDisplaySettingsViewModel + @State private var selectedID: Int? var body: some View { Form { @@ -17,28 +18,56 @@ struct InfoDisplaySettingsView: View { } } - Section(header: Text("Information Display Settings")) { - ForEach(viewModel.infoSort, id: \.self) { sortedIndex in - HStack { - Text(viewModel.getName(for: sortedIndex)) - Spacer() - Toggle("", isOn: Binding( - get: { viewModel.infoVisible[sortedIndex] }, - set: { _ in - viewModel.toggleVisibility(for: sortedIndex) - } - )) - .labelsHidden() + Section( + header: Text("Information Display Settings"), + footer: Text("Drag to reorder. Tap a row to set its visibility and colors.") + ) { + // The list stays in edit mode so rows are always draggable; a + // Button (unlike NavigationLink) still receives taps in edit + // mode, so reordering and navigation work at the same time. + ForEach(viewModel.items) { item in + Button { + selectedID = item.id + } label: { + rowLabel(for: item) } + .buttonStyle(.plain) } .onMove(perform: viewModel.move) } } .environment(\.editMode, .constant(.active)) + .navigationDestination(isPresented: Binding( + get: { selectedID != nil }, + set: { if !$0 { selectedID = nil } } + )) { + if let id = selectedID { + InfoRowSettingsView(item: viewModel.binding(for: id)) + } + } .onDisappear { NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Information Display Settings", displayMode: .inline) } + + private func rowLabel(for item: InfoDisplayItem) -> some View { + HStack { + Text(item.type.name) + Spacer() + if item.type.isColorable, item.coloring.enabled { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + .accessibilityLabel("Coloring enabled") + } + Text(item.isVisible ? "On" : "Off") + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } } diff --git a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift index e77d67f69..c951805d4 100644 --- a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift +++ b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift @@ -5,28 +5,31 @@ import Foundation import SwiftUI class InfoDisplaySettingsViewModel: ObservableObject { - @Published var infoSort: [Int] - @Published var infoVisible: [Bool] + @Published var items: [InfoDisplayItem] init() { - infoSort = Storage.shared.infoSort.value - infoVisible = Storage.shared.infoVisible.value + items = Storage.shared.infoDisplayItems.value } - func toggleVisibility(for sortedIndex: Int) { - infoVisible[sortedIndex].toggle() - Storage.shared.infoVisible.value = infoVisible + func move(from source: IndexSet, to destination: Int) { + items.move(fromOffsets: source, toOffset: destination) + persist() } - func move(from source: IndexSet, to destination: Int) { - infoSort.move(fromOffsets: source, toOffset: destination) - Storage.shared.infoSort.value = infoSort + /// A binding to a single item, looked up by id so it survives reordering. + /// Writes persist to Storage immediately. + func binding(for id: Int) -> Binding { + Binding( + get: { self.items.first(where: { $0.id == id }) ?? self.items[0] }, + set: { newValue in + guard let index = self.items.firstIndex(where: { $0.id == id }) else { return } + self.items[index] = newValue + self.persist() + } + ) } - func getName(for index: Int) -> String { - guard let infoType = InfoType(rawValue: index) else { - return "Unknown" - } - return infoType.name + private func persist() { + Storage.shared.infoDisplayItems.value = items } } diff --git a/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift new file mode 100644 index 000000000..7f12999dc --- /dev/null +++ b/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift @@ -0,0 +1,93 @@ +// LoopFollow +// InfoRowSettingsView.swift + +import SwiftUI + +struct InfoRowSettingsView: View { + @Binding var item: InfoDisplayItem + + var body: some View { + Form { + Section { + Toggle("Show in Info Display", isOn: $item.isVisible) + } + + if item.type.isColorable { + Section( + header: Text("Color"), + footer: Text(colorFooter) + ) { + Toggle("Enable coloring", isOn: $item.coloring.enabled) + + if item.coloring.enabled { + thresholdField(title: "Yellow at", value: $item.coloring.warning) + thresholdField(title: "Red at", value: $item.coloring.urgent) + + if let thresholdWarning { + Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.orange) + } + } + } + } + } + .navigationBarTitle(item.type.name, displayMode: .inline) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + /// Non-blocking sanity check: red should be more severe than yellow, in the + /// metric's fixed direction. `nil` when consistent or a threshold is unset. + private var thresholdWarning: String? { + guard item.coloring.enabled, + let warning = item.coloring.warning, + let urgent = item.coloring.urgent + else { return nil } + switch item.type.colorDirection { + case .above: + return urgent < warning ? "Red should be at or above yellow." : nil + case .below: + return urgent > warning ? "Red should be at or below yellow." : nil + } + } + + private var colorFooter: String { + switch item.type.colorDirection { + case .above: + return "The value turns yellow at or above the yellow level and red at or above the red level. In range it shows green. A visual cue only — it never triggers an alarm." + case .below: + return "The value turns yellow at or below the yellow level and red at or below the red level. In range it shows green. A visual cue only — it never triggers an alarm." + } + } + + private func thresholdField(title: String, value: Binding) -> some View { + HStack { + Text(title) + Spacer() + TextField("—", text: text(for: value)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 120) + if let unit = item.type.colorUnitLabel { + Text(unit).foregroundStyle(.secondary) + } + } + } + + /// Bridges an optional Double threshold to an editable text field. + /// Empty text clears the threshold (that color is then unused); accepts both + /// "." and "," as the decimal separator. + private func text(for value: Binding) -> Binding { + Binding( + get: { value.wrappedValue.map { formatted($0) } ?? "" }, + set: { newText in + let normalized = newText.replacingOccurrences(of: ",", with: ".") + value.wrappedValue = normalized.isEmpty ? nil : Double(normalized) + } + ) + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(value) + } +} diff --git a/LoopFollow/InfoTable/InfoData.swift b/LoopFollow/InfoTable/InfoData.swift index 30f5bf9e9..56a8e8e88 100644 --- a/LoopFollow/InfoTable/InfoData.swift +++ b/LoopFollow/InfoTable/InfoData.swift @@ -7,10 +7,14 @@ class InfoData: Identifiable { let id: Int let name: String var value: String + /// Raw numeric value behind `value`, when the row carries a single number. + /// Used for threshold-based coloring; `nil` for text or combined values. + var numericValue: Double? - init(id: Int, name: String, value: String = "") { + init(id: Int, name: String, value: String = "", numericValue: Double? = nil) { self.id = id self.name = name self.value = value + self.numericValue = numericValue } } diff --git a/LoopFollow/InfoTable/InfoDisplayItem.swift b/LoopFollow/InfoTable/InfoDisplayItem.swift new file mode 100644 index 000000000..a7d91096e --- /dev/null +++ b/LoopFollow/InfoTable/InfoDisplayItem.swift @@ -0,0 +1,56 @@ +// LoopFollow +// InfoDisplayItem.swift + +import SwiftUI + +/// One configurable row in the Info Display: which info it shows, whether it is +/// visible, and its optional threshold-based coloring. The ordered array of +/// these replaces the old parallel `infoSort` / `infoVisible` storage. +struct InfoDisplayItem: Codable, Equatable, Identifiable { + var type: InfoType + var isVisible: Bool + var coloring: InfoColoring + + var id: Int { type.rawValue } +} + +extension Array where Element == InfoDisplayItem { + func item(for type: InfoType) -> InfoDisplayItem? { + first { $0.type == type } + } + + func isVisible(_ type: InfoType) -> Bool { + item(for: type)?.isVisible ?? false + } +} + +/// Which direction is "concerning" for a metric. Fixed per InfoType (battery is +/// bad when low, sensor age is bad when high) rather than user-configurable. +enum InfoColorDirection { + case above // color as the value rises (e.g. SAGE, IOB) + case below // color as the value falls (e.g. Battery) +} + +/// Per-row, opt-in color thresholds. Purely visual — never triggers an alarm. +struct InfoColoring: Codable, Equatable { + var enabled: Bool = false + var warning: Double? = nil // yellow + var urgent: Double? = nil // red + + /// Resolves the color for a raw numeric value, given the metric's fixed + /// direction. Returns `nil` only when coloring is off (callers map that to + /// the default text color); when enabled, an in-range value reads green, + /// mirroring the app's BG text coloring. + func color(for value: Double, direction: InfoColorDirection) -> Color? { + guard enabled else { return nil } + switch direction { + case .above: + if let urgent, value >= urgent { return .red } + if let warning, value >= warning { return .yellow } + case .below: + if let urgent, value <= urgent { return .red } + if let warning, value <= warning { return .yellow } + } + return .green + } +} diff --git a/LoopFollow/InfoTable/InfoManager.swift b/LoopFollow/InfoTable/InfoManager.swift index f3205511e..3fd32e0eb 100644 --- a/LoopFollow/InfoTable/InfoManager.swift +++ b/LoopFollow/InfoTable/InfoManager.swift @@ -12,14 +12,16 @@ class InfoManager: ObservableObject { tableData = InfoType.allCases.map { InfoData(id: $0.rawValue, name: $0.name) } } - func updateInfoData(type: InfoType, value: String) { + func updateInfoData(type: InfoType, value: String, numericValue: Double? = nil) { tableData[type.rawValue].value = value + tableData[type.rawValue].numericValue = numericValue objectWillChange.send() } func updateInfoData(type: InfoType, value: HKQuantity) { let formattedValue = Localizer.formatQuantity(value) - updateInfoData(type: type, value: formattedValue) + let numericValue = value.doubleValue(for: Localizer.getPreferredUnit()) + updateInfoData(type: type, value: formattedValue, numericValue: numericValue) } func updateInfoData(type: InfoType, firstValue: HKQuantity, secondValue: HKQuantity, separator: InfoDataSeparator) { @@ -35,7 +37,7 @@ class InfoManager: ObservableObject { func updateInfoData(type: InfoType, value: Double, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) { let formattedValue = Localizer.formatToLocalizedString(value, maxFractionDigits: maxFractionDigits, minFractionDigits: minFractionDigits) - updateInfoData(type: type, value: formattedValue) + updateInfoData(type: type, value: formattedValue, numericValue: value) } func updateInfoData(type: InfoType, value: Double, enactedValue: Double, separator: InfoDataSeparator, maxFractionDigits: Int = 1, minFractionDigits: Int = 0) { @@ -48,25 +50,28 @@ class InfoManager: ObservableObject { func updateInfoData(type: InfoType, value: Metric) { let formattedValue = value.formattedValue() - updateInfoData(type: type, value: formattedValue) + updateInfoData(type: type, value: formattedValue, numericValue: value.value) } func clearInfoData(type: InfoType) { tableData[type.rawValue].value = "" + tableData[type.rawValue].numericValue = nil objectWillChange.send() } func clearInfoData(types: [InfoType]) { for type in types { tableData[type.rawValue].value = "" + tableData[type.rawValue].numericValue = nil } objectWillChange.send() } var visibleRows: [InfoData] { - Storage.shared.infoSort.value - .filter { $0 < Storage.shared.infoVisible.value.count && Storage.shared.infoVisible.value[$0] } - .compactMap { index in + Storage.shared.infoDisplayItems.value + .filter { $0.isVisible } + .compactMap { item in + let index = item.type.rawValue guard index < tableData.count else { return nil } return tableData[index] } diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index 948cd94d2..842532912 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -16,14 +16,23 @@ struct InfoTableView: View { row(name: "Time Zone", value: tz) } ForEach(infoManager.visibleRows) { item in - row(name: item.name, value: item.value) + row(name: item.name, value: item.value, valueColor: color(for: item)) } } .listStyle(.plain) .environment(\.defaultMinListRowHeight, rowHeight) } - private func row(name: String, value: String) -> some View { + /// Threshold-based color for a row's value, or nil to use the default color. + private func color(for item: InfoData) -> Color? { + guard let numericValue = item.numericValue, + let type = InfoType(rawValue: item.id) + else { return nil } + return Storage.shared.infoDisplayItems.value.item(for: type)? + .coloring.color(for: numericValue, direction: type.colorDirection) + } + + private func row(name: String, value: String, valueColor: Color? = nil) -> some View { // Show a placeholder for any field that has no value yet, // so the row reads as "no data" rather than appearing empty. let displayValue = value.isEmpty ? "—" : value @@ -34,14 +43,14 @@ struct InfoTableView: View { Text(name) Spacer() Text(displayValue) - .foregroundStyle(.primary) + .foregroundStyle(valueColor ?? .primary) } // Fallback when the single line won't fit: label over value VStack(alignment: .leading, spacing: 0) { Text(name) Text(displayValue) - .foregroundStyle(.primary) + .foregroundStyle(valueColor ?? .primary) .frame(maxWidth: .infinity, alignment: .trailing) } } diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index 14d0eb335..9cdfcd3a5 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -3,7 +3,7 @@ import Foundation -enum InfoType: Int, CaseIterable { +enum InfoType: Int, CaseIterable, Codable { case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage var name: String { @@ -43,4 +43,43 @@ enum InfoType: Int, CaseIterable { var sortOrder: Int { return rawValue } + + /// Rows that carry a single numeric value can offer color thresholds. + /// Combined rows (basal, min/max), BG-unit rows (target, ISF, CR) and + /// pure-text rows are excluded for now. + var isColorable: Bool { + switch self { + case .iob, .cob, .battery, .pumpBattery, .tdd, .recBolus, .carbsToday, .sage, .cage, .iage: + return true + default: + return false + } + } + + /// The "concerning" direction for this metric, used by threshold coloring. + /// Fixed per type: battery is bad when low, most others when high. + var colorDirection: InfoColorDirection { + switch self { + case .battery, .pumpBattery: + return .below + default: + return .above + } + } + + /// Unit shown next to the threshold fields so the user knows what to type. + var colorUnitLabel: String? { + switch self { + case .battery, .pumpBattery: + return "%" + case .iob, .recBolus, .tdd: + return "U" + case .cob, .carbsToday: + return "g" + case .sage, .cage, .iage: + return "days" + default: + return nil + } + } } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index b2274675d..5854fab17 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -60,6 +60,45 @@ extension Storage { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) } + func migrateStep10() { + LogManager.shared.log(category: .general, message: "Running migrateStep10 — infoSort/infoVisible → infoDisplayItems") + migrateInfoDisplayItems() + } + + /// Converts the legacy parallel `infoSort` / `infoVisible` arrays into the + /// unified `infoDisplayItems` store, then removes the legacy keys. + /// + /// Idempotent — does nothing once the legacy keys are gone. Called from + /// `migrateStep10()` and, as a safety net, unconditionally from + /// `synchronizeInfoTypes()`: fresh installs never persist a `migrationStep`, + /// so a user who customized their info display before this release would + /// otherwise have the step-10 guard skip their conversion. + func migrateInfoDisplayItems() { + let legacySort = StorageValue<[Int]>(key: "infoSort", defaultValue: []) + let legacyVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: []) + guard legacySort.exists || legacyVisible.exists else { return } + + let sort = legacySort.value + let visible = legacyVisible.value + + var items: [InfoDisplayItem] = [] + var seen = Set() + // Honor the saved order and per-index visibility. + for index in sort { + guard let type = InfoType(rawValue: index), seen.insert(index).inserted else { continue } + let isVisible = index < visible.count ? visible[index] : type.defaultVisible + items.append(InfoDisplayItem(type: type, isVisible: isVisible, coloring: InfoColoring())) + } + // Append any InfoType missing from the saved order (new cases, gaps). + for type in InfoType.allCases where seen.insert(type.rawValue).inserted { + items.append(InfoDisplayItem(type: type, isVisible: type.defaultVisible, coloring: InfoColoring())) + } + + Storage.shared.infoDisplayItems.value = items + legacySort.remove() + legacyVisible.remove() + } + func migrateStep9() { // Default for debugLogLevel changed from false to true so users ship useful // logs when they report a problem. Force-enable for existing users. @@ -509,13 +548,15 @@ extension Storage { move(UserDefaultsValue(key: "token", default: ""), into: Storage.shared.token) move(UserDefaultsValue(key: "units", default: "mg/dL"), into: Storage.shared.units) + // Move the legacy app-group arrays into the standard keys; migrateStep10 + // (or migrateInfoDisplayItems) then folds them into infoDisplayItems. move(UserDefaultsValue<[Int]>(key: "infoSort", default: InfoType.allCases.map { $0.sortOrder }), - into: Storage.shared.infoSort) + into: StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder })) move(UserDefaultsValue<[Bool]>(key: "infoVisible", default: InfoType.allCases.map { $0.defaultVisible }), - into: Storage.shared.infoVisible) + into: StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible })) migrateUrgentLowAlarm() migrateLowAlarm() diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4dd7370a8..9a2dc6fc1 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -203,8 +203,12 @@ class Storage { var units = StorageValue(key: "units", defaultValue: "mg/dL") var hasConfiguredUnits = StorageValue(key: "hasConfiguredUnits", defaultValue: false) - var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) - var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) + var infoDisplayItems = StorageValue<[InfoDisplayItem]>( + key: "infoDisplayItems", + defaultValue: InfoType.allCases.map { + InfoDisplayItem(type: $0, isVisible: $0.defaultVisible, coloring: InfoColoring()) + } + ) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") @@ -215,7 +219,7 @@ class Storage { // When adding a new migration step in `runMigrationsIfNeeded()`, bump this default // to the new latest step number so fresh installs skip all migrations. Other defaults // in this file must reflect the post-migration final state for a fresh install. - var migrationStep = StorageValue(key: "migrationStep", defaultValue: 9) + var migrationStep = StorageValue(key: "migrationStep", defaultValue: 10) var persistentNotification = StorageValue(key: "persistentNotification", defaultValue: false) var persistentNotificationLastBGTime = StorageValue(key: "persistentNotificationLastBGTime", defaultValue: .distantPast) @@ -407,8 +411,7 @@ class Storage { hideInfoTable.reload() token.reload() units.reload() - infoSort.reload() - infoVisible.reload() + infoDisplayItems.reload() url.reload() device.reload() diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 105873f1c..d6f32c18d 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -697,6 +697,11 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio Storage.shared.migrateStep9() Storage.shared.migrationStep.value = 9 } + + if Storage.shared.migrationStep.value < 10 { + Storage.shared.migrateStep10() + Storage.shared.migrationStep.value = 10 + } } @objc func appDidBecomeActive() { @@ -1042,36 +1047,23 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } private func synchronizeInfoTypes() { - var sortArray = Storage.shared.infoSort.value - var visibleArray = Storage.shared.infoVisible.value - - // Current valid indices based on InfoType - let currentValidIndices = InfoType.allCases.map { $0.rawValue } - - // Add missing indices to sortArray - for index in currentValidIndices { - if !sortArray.contains(index) { - sortArray.append(index) - } - } + // Safety net: fold any leftover legacy infoSort/infoVisible into + // infoDisplayItems for users whose migrationStep predates that key. + Storage.shared.migrateInfoDisplayItems() - // Remove deprecated indices - sortArray = sortArray.filter { currentValidIndices.contains($0) } + var items = Storage.shared.infoDisplayItems.value - // Ensure visibleArray is updated with new entries - if visibleArray.count < currentValidIndices.count { - for i in visibleArray.count ..< currentValidIndices.count { - visibleArray.append(InfoType(rawValue: i)?.defaultVisible ?? false) - } - } + // Drop items whose InfoType no longer exists. + let validTypes = Set(InfoType.allCases) + items.removeAll { !validTypes.contains($0.type) } - // Trim excess elements if there are more than needed - if visibleArray.count > currentValidIndices.count { - visibleArray = Array(visibleArray.prefix(currentValidIndices.count)) + // Append any newly added InfoType. + let present = Set(items.map(\.type)) + for type in InfoType.allCases where !present.contains(type) { + items.append(InfoDisplayItem(type: type, isVisible: type.defaultVisible, coloring: InfoColoring())) } - Storage.shared.infoSort.value = sortArray - Storage.shared.infoVisible.value = visibleArray + Storage.shared.infoDisplayItems.value = items } // MARK: - First Time Setup diff --git a/Tests/InfoColoringTests.swift b/Tests/InfoColoringTests.swift new file mode 100644 index 000000000..44242a7b2 --- /dev/null +++ b/Tests/InfoColoringTests.swift @@ -0,0 +1,47 @@ +// LoopFollow +// InfoColoringTests.swift + +@testable import LoopFollow +import SwiftUI +import Testing + +struct InfoColoringTests { + @Test("disabled coloring returns nil regardless of value") + func disabledReturnsNil() { + let coloring = InfoColoring(enabled: false, warning: 30, urgent: 15) + #expect(coloring.color(for: 5, direction: .below) == nil) + } + + @Test("below: battery warn 30 / urgent 15, in-range is green") + func belowDirection() { + let coloring = InfoColoring(enabled: true, warning: 30, urgent: 15) + #expect(coloring.color(for: 40, direction: .below) == .green) + #expect(coloring.color(for: 30, direction: .below) == .yellow) + #expect(coloring.color(for: 25, direction: .below) == .yellow) + #expect(coloring.color(for: 15, direction: .below) == .red) + #expect(coloring.color(for: 10, direction: .below) == .red) + } + + @Test("above: SAGE warn 8 / urgent 9.5, in-range is green") + func aboveDirection() { + let coloring = InfoColoring(enabled: true, warning: 8, urgent: 9.5) + #expect(coloring.color(for: 6, direction: .above) == .green) + #expect(coloring.color(for: 8, direction: .above) == .yellow) + #expect(coloring.color(for: 8.5, direction: .above) == .yellow) + #expect(coloring.color(for: 9.5, direction: .above) == .red) + #expect(coloring.color(for: 12, direction: .above) == .red) + } + + @Test("nil thresholds are green when enabled") + func nilThresholds() { + let coloring = InfoColoring(enabled: true, warning: nil, urgent: nil) + #expect(coloring.color(for: 999, direction: .above) == .green) + } + + @Test("only urgent set still colors red, otherwise green") + func onlyUrgent() { + let coloring = InfoColoring(enabled: true, warning: nil, urgent: 15) + #expect(coloring.color(for: 20, direction: .below) == .green) + #expect(coloring.color(for: 15, direction: .below) == .red) + } +} From 95559d8c7495235704f87f21879793c76c2c7ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 8 Jul 2026 09:53:58 +0200 Subject: [PATCH 2/2] Use steppers for Info Display color thresholds, and color the pump row Threshold entry now uses steppers with a per-type range and step, mirroring the alarm editors, so values that need decimals (IOB, rec. bolus, the ages) accept them. The old text fields re-parsed on every keystroke and swallowed the decimal separator, making fractional thresholds impossible to enter. Direction, unit, bounds and defaults move into a single InfoColorConfig per type, so isColorable is simply "has a config". The pump reservoir row is now colorable, low-is-concerning. Pumps that only report "50+" count as exactly 50, matching what the reservoir alarm already does with latestPumpVolume. --- .../Controllers/Nightscout/DeviceStatus.swift | 6 +- .../InfoRowSettingsView.swift | 88 ++++++++++--------- LoopFollow/InfoTable/InfoDisplayItem.swift | 19 +++- LoopFollow/InfoTable/InfoTableView.swift | 5 +- LoopFollow/InfoTable/InfoType.swift | 53 +++++------ Tests/InfoColoringTests.swift | 24 +++++ 6 files changed, 119 insertions(+), 76 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 065c9f5a9..390c715ed 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -113,11 +113,13 @@ extension MainViewController { if let reservoirData = lastPumpRecord["reservoir"] as? Double { latestPumpVolume = reservoirData - infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") + infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData) Storage.shared.lastPumpReservoirU.value = reservoirData } else { + // Pumps that only report "50+" get treated as exactly 50, both + // for the volume alarm and for the info row's coloring. latestPumpVolume = 50.0 - infoManager.updateInfoData(type: .pump, value: "50+U") + infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift index 7f12999dc..9387949cc 100644 --- a/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift +++ b/LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift @@ -12,18 +12,18 @@ struct InfoRowSettingsView: View { Toggle("Show in Info Display", isOn: $item.isVisible) } - if item.type.isColorable { + if let config = item.type.colorConfig { Section( header: Text("Color"), - footer: Text(colorFooter) + footer: Text(colorFooter(config)) ) { - Toggle("Enable coloring", isOn: $item.coloring.enabled) + Toggle("Enable coloring", isOn: enabledBinding(config)) if item.coloring.enabled { - thresholdField(title: "Yellow at", value: $item.coloring.warning) - thresholdField(title: "Red at", value: $item.coloring.urgent) + thresholdRow(title: "Yellow at", value: $item.coloring.warning, default: config.defaultWarning, config: config) + thresholdRow(title: "Red at", value: $item.coloring.urgent, default: config.defaultUrgent, config: config) - if let thresholdWarning { + if let thresholdWarning = thresholdWarning(config) { Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill") .font(.footnote) .foregroundStyle(.orange) @@ -36,14 +36,51 @@ struct InfoRowSettingsView: View { .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) } + /// Turning coloring on seeds any unset threshold, so the steppers and the + /// stored values always agree. + private func enabledBinding(_ config: InfoColorConfig) -> Binding { + Binding( + get: { item.coloring.enabled }, + set: { isOn in + if isOn { + item.coloring.warning = item.coloring.warning ?? config.defaultWarning + item.coloring.urgent = item.coloring.urgent ?? config.defaultUrgent + } + item.coloring.enabled = isOn + } + ) + } + + private func thresholdRow(title: String, value: Binding, default defaultValue: Double, config: InfoColorConfig) -> some View { + SettingsStepperRow( + title: title, + range: config.range, + step: config.step, + value: Binding( + get: { value.wrappedValue ?? defaultValue }, + set: { value.wrappedValue = $0 } + ), + format: { formatted($0, config) } + ) + } + + private func formatted(_ value: Double, _ config: InfoColorConfig) -> String { + let number = Localizer.formatToLocalizedString( + value, + maxFractionDigits: config.fractionDigits, + minFractionDigits: config.fractionDigits + ) + return "\(number) \(config.unit)" + } + /// Non-blocking sanity check: red should be more severe than yellow, in the /// metric's fixed direction. `nil` when consistent or a threshold is unset. - private var thresholdWarning: String? { + private func thresholdWarning(_ config: InfoColorConfig) -> String? { guard item.coloring.enabled, let warning = item.coloring.warning, let urgent = item.coloring.urgent else { return nil } - switch item.type.colorDirection { + switch config.direction { case .above: return urgent < warning ? "Red should be at or above yellow." : nil case .below: @@ -51,43 +88,12 @@ struct InfoRowSettingsView: View { } } - private var colorFooter: String { - switch item.type.colorDirection { + private func colorFooter(_ config: InfoColorConfig) -> String { + switch config.direction { case .above: return "The value turns yellow at or above the yellow level and red at or above the red level. In range it shows green. A visual cue only — it never triggers an alarm." case .below: return "The value turns yellow at or below the yellow level and red at or below the red level. In range it shows green. A visual cue only — it never triggers an alarm." } } - - private func thresholdField(title: String, value: Binding) -> some View { - HStack { - Text(title) - Spacer() - TextField("—", text: text(for: value)) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: 120) - if let unit = item.type.colorUnitLabel { - Text(unit).foregroundStyle(.secondary) - } - } - } - - /// Bridges an optional Double threshold to an editable text field. - /// Empty text clears the threshold (that color is then unused); accepts both - /// "." and "," as the decimal separator. - private func text(for value: Binding) -> Binding { - Binding( - get: { value.wrappedValue.map { formatted($0) } ?? "" }, - set: { newText in - let normalized = newText.replacingOccurrences(of: ",", with: ".") - value.wrappedValue = normalized.isEmpty ? nil : Double(normalized) - } - ) - } - - private func formatted(_ value: Double) -> String { - value == value.rounded() ? String(Int(value)) : String(value) - } } diff --git a/LoopFollow/InfoTable/InfoDisplayItem.swift b/LoopFollow/InfoTable/InfoDisplayItem.swift index a7d91096e..cf3a3c1b9 100644 --- a/LoopFollow/InfoTable/InfoDisplayItem.swift +++ b/LoopFollow/InfoTable/InfoDisplayItem.swift @@ -28,7 +28,24 @@ extension Array where Element == InfoDisplayItem { /// bad when low, sensor age is bad when high) rather than user-configurable. enum InfoColorDirection { case above // color as the value rises (e.g. SAGE, IOB) - case below // color as the value falls (e.g. Battery) + case below // color as the value falls (e.g. Battery, Pump) +} + +/// What threshold coloring looks like for one InfoType: the concerning +/// direction, the unit shown next to the value, and the bounds the threshold +/// steppers move in. Only types that expose one of these can be colored. +struct InfoColorConfig { + let direction: InfoColorDirection + let unit: String + let range: ClosedRange + let step: Double + let defaultWarning: Double + let defaultUrgent: Double + + /// Decimals to show, derived from the step (0.5 → 1, 5 → 0). + var fractionDigits: Int { + step.truncatingRemainder(dividingBy: 1) == 0 ? 0 : 1 + } } /// Per-row, opt-in color thresholds. Purely visual — never triggers an alarm. diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index 842532912..6c36c6bbe 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -26,10 +26,11 @@ struct InfoTableView: View { /// Threshold-based color for a row's value, or nil to use the default color. private func color(for item: InfoData) -> Color? { guard let numericValue = item.numericValue, - let type = InfoType(rawValue: item.id) + let type = InfoType(rawValue: item.id), + let config = type.colorConfig else { return nil } return Storage.shared.infoDisplayItems.value.item(for: type)? - .coloring.color(for: numericValue, direction: type.colorDirection) + .coloring.color(for: numericValue, direction: config.direction) } private func row(name: String, value: String, valueColor: Color? = nil) -> some View { diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index 9cdfcd3a5..a54260f36 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -46,40 +46,33 @@ enum InfoType: Int, CaseIterable, Codable { /// Rows that carry a single numeric value can offer color thresholds. /// Combined rows (basal, min/max), BG-unit rows (target, ISF, CR) and - /// pure-text rows are excluded for now. - var isColorable: Bool { - switch self { - case .iob, .cob, .battery, .pumpBattery, .tdd, .recBolus, .carbsToday, .sage, .cage, .iage: - return true - default: - return false - } - } - - /// The "concerning" direction for this metric, used by threshold coloring. - /// Fixed per type: battery is bad when low, most others when high. - var colorDirection: InfoColorDirection { + /// pure-text rows have no config and are therefore not colorable. + /// Steps mirror the equivalent alarm editors, so a value that takes decimals + /// in an alarm takes decimals here too. + var colorConfig: InfoColorConfig? { switch self { + case .iob: + return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.5, defaultWarning: 3, defaultUrgent: 5) + case .cob: + return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 200, step: 1, defaultWarning: 30, defaultUrgent: 60) case .battery, .pumpBattery: - return .below - default: - return .above - } - } - - /// Unit shown next to the threshold fields so the user knows what to type. - var colorUnitLabel: String? { - switch self { - case .battery, .pumpBattery: - return "%" - case .iob, .recBolus, .tdd: - return "U" - case .cob, .carbsToday: - return "g" - case .sage, .cage, .iage: - return "days" + return InfoColorConfig(direction: .below, unit: "%", range: 0 ... 100, step: 5, defaultWarning: 30, defaultUrgent: 15) + case .pump: + return InfoColorConfig(direction: .below, unit: "U", range: 0 ... 50, step: 1, defaultWarning: 20, defaultUrgent: 10) + case .tdd: + return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 200, step: 1, defaultWarning: 60, defaultUrgent: 80) + case .recBolus: + return InfoColorConfig(direction: .above, unit: "U", range: 0 ... 20, step: 0.1, defaultWarning: 1, defaultUrgent: 2) + case .carbsToday: + return InfoColorConfig(direction: .above, unit: "g", range: 0 ... 500, step: 5, defaultWarning: 150, defaultUrgent: 250) + case .sage: + return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 15, step: 0.5, defaultWarning: 9, defaultUrgent: 9.5) + case .cage, .iage: + return InfoColorConfig(direction: .above, unit: "days", range: 0.5 ... 10, step: 0.5, defaultWarning: 2.5, defaultUrgent: 3) default: return nil } } + + var isColorable: Bool { colorConfig != nil } } diff --git a/Tests/InfoColoringTests.swift b/Tests/InfoColoringTests.swift index 44242a7b2..1009764d8 100644 --- a/Tests/InfoColoringTests.swift +++ b/Tests/InfoColoringTests.swift @@ -44,4 +44,28 @@ struct InfoColoringTests { #expect(coloring.color(for: 20, direction: .below) == .green) #expect(coloring.color(for: 15, direction: .below) == .red) } + + @Test("pump reservoir colors on the way down, and a capped 50+ reads green") + func pumpReservoir() throws { + let config = try #require(InfoType.pump.colorConfig) + let coloring = InfoColoring(enabled: true, warning: config.defaultWarning, urgent: config.defaultUrgent) + #expect(coloring.color(for: 50, direction: config.direction) == .green) + #expect(coloring.color(for: 20, direction: config.direction) == .yellow) + #expect(coloring.color(for: 10, direction: config.direction) == .red) + } + + @Test("thresholds that need decimals get a fractional step") + func fractionalSteps() { + #expect(InfoType.recBolus.colorConfig?.step == 0.1) + #expect(InfoType.iob.colorConfig?.step == 0.5) + #expect(InfoType.sage.colorConfig?.fractionDigits == 1) + #expect(InfoType.cob.colorConfig?.fractionDigits == 0) + } + + @Test("non-numeric rows are not colorable") + func notColorable() { + #expect(InfoType.basal.isColorable == false) + #expect(InfoType.minMax.isColorable == false) + #expect(InfoType.pump.isColorable) + } }