Просмотр исходного кода

Add color thresholds to the Info Display (#693)

* Add color thresholds to the Info Display

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.

* 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.
Jonas Björkert 1 неделя назад
Родитель
Сommit
c56d0b3645

+ 8 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -115,6 +115,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 */; };
@@ -583,6 +585,8 @@
 		DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = "<group>"; };
 		DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = "<group>"; };
 		DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = "<group>"; };
+		DDA0000012341234000A0001 /* InfoDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplayItem.swift; sourceTree = "<group>"; };
+		DDA0000012341234000B0001 /* InfoRowSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRowSettingsView.swift; sourceTree = "<group>"; };
 		DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = "<group>"; };
 		DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
 		DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
@@ -1183,6 +1187,7 @@
 				DD13BC742C3FD6200062313B /* InfoType.swift */,
 				DD13BC762C3FD64E0062313B /* InfoData.swift */,
 				DD13BC782C3FE63A0062313B /* InfoManager.swift */,
+				DDA0000012341234000A0001 /* InfoDisplayItem.swift */,
 				DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */,
 				DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */,
 			);
@@ -1265,6 +1270,7 @@
 			children = (
 				DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */,
 				DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */,
+				DDA0000012341234000B0001 /* InfoRowSettingsView.swift */,
 			);
 			path = InfoDisplaySettings;
 			sourceTree = "<group>";
@@ -2331,6 +2337,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 */,

+ 1 - 1
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)
             }
         }
     }

+ 6 - 4
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -114,11 +114,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
                 }
             }
@@ -127,7 +129,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
             }
 
@@ -141,7 +143,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
                 Observable.shared.deviceBatteryIsCharging.value = isCharging
 

+ 1 - 1
LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

@@ -101,7 +101,7 @@ extension MainViewController {
                 updatePredictionGraph()
             }
             if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
-                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus))
+                infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus)
                 Observable.shared.deviceRecBolus.value = recBolus
             } else {
                 infoManager.clearInfoData(type: .recBolus)

+ 1 - 1
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)
             }
         }
     }

+ 1 - 1
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)
             }
         }
     }

+ 1 - 1
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
     }
 }

+ 1 - 1
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()
         }
     }

+ 41 - 12
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())
+    }
 }

+ 18 - 15
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<InfoDisplayItem> {
+        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
     }
 }

+ 99 - 0
LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift

@@ -0,0 +1,99 @@
+// 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 let config = item.type.colorConfig {
+                Section(
+                    header: Text("Color"),
+                    footer: Text(colorFooter(config))
+                ) {
+                    Toggle("Enable coloring", isOn: enabledBinding(config))
+
+                    if item.coloring.enabled {
+                        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 = thresholdWarning(config) {
+                            Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill")
+                                .font(.footnote)
+                                .foregroundStyle(.orange)
+                        }
+                    }
+                }
+            }
+        }
+        .navigationBarTitle(item.type.name, displayMode: .inline)
+        .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<Bool> {
+        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<Double?>, 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 func thresholdWarning(_ config: InfoColorConfig) -> String? {
+        guard item.coloring.enabled,
+              let warning = item.coloring.warning,
+              let urgent = item.coloring.urgent
+        else { return nil }
+        switch config.direction {
+        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 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."
+        }
+    }
+}

+ 5 - 1
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
     }
 }

+ 73 - 0
LoopFollow/InfoTable/InfoDisplayItem.swift

@@ -0,0 +1,73 @@
+// 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, 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<Double>
+    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.
+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
+    }
+}

+ 12 - 7
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]
             }

+ 14 - 4
LoopFollow/InfoTable/InfoTableView.swift

@@ -16,14 +16,24 @@ 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),
+              let config = type.colorConfig
+        else { return nil }
+        return Storage.shared.infoDisplayItems.value.item(for: type)?
+            .coloring.color(for: numericValue, direction: config.direction)
+    }
+
+    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 +44,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)
             }
         }

+ 33 - 1
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, dbSize
 
     var name: String {
@@ -44,4 +44,36 @@ 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 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 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 }
 }

+ 39 - 0
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<Int>()
+        // 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.

+ 7 - 3
LoopFollow/Storage/Storage.swift

@@ -203,8 +203,12 @@ class Storage {
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
     var hasConfiguredUnits = StorageValue<Bool>(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<String>(key: "url", defaultValue: "")
     var device = StorageValue<String>(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<Int>(key: "migrationStep", defaultValue: 9)
+    var migrationStep = StorageValue<Int>(key: "migrationStep", defaultValue: 10)
 
     var persistentNotification = StorageValue<Bool>(key: "persistentNotification", defaultValue: false)
     var persistentNotificationLastBGTime = StorageValue<Date>(key: "persistentNotificationLastBGTime", defaultValue: .distantPast)

+ 17 - 25
LoopFollow/ViewControllers/MainViewController.swift

@@ -658,6 +658,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
             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() {
@@ -994,36 +999,23 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate {
     }
 
     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

+ 71 - 0
Tests/InfoColoringTests.swift

@@ -0,0 +1,71 @@
+// 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)
+    }
+
+    @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)
+    }
+}