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

Add IOB display option for contact watch face (#512)

Ľubor Jurena 3 месяцев назад
Родитель
Сommit
ebba6882d4

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -173,6 +173,7 @@
 		DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
 		DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
 		DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; };
+		DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */; };
 		DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; };
 		DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
 		DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; };
@@ -580,6 +581,7 @@
 		DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
 		DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
 		DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = "<group>"; };
+		DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorMode.swift; sourceTree = "<group>"; };
 		DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactIncludeOption.swift; sourceTree = "<group>"; };
 		DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactType.swift; sourceTree = "<group>"; };
 		DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
@@ -1029,6 +1031,7 @@
 				DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
 				DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
 				DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
+				DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */,
 				DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
 			);
 			path = Contact;
@@ -2062,6 +2065,7 @@
 				DDC7E5462DBD8A1600EB1127 /* LowBgAlarmEditor.swift in Sources */,
 				DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */,
 				DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
+				DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */,
 				DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */,
 				DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
 				FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */,

+ 37 - 0
LoopFollow/Contact/ContactColorMode.swift

@@ -0,0 +1,37 @@
+// LoopFollow
+// ContactColorMode.swift
+
+import UIKit
+
+enum ContactColorMode: String, Codable, CaseIterable {
+    case staticColor = "Static"
+    case dynamic = "Dynamic"
+
+    var displayName: String {
+        switch self {
+        case .staticColor:
+            return "Static"
+        case .dynamic:
+            return "Dynamic (BG Range)"
+        }
+    }
+
+    /// Returns the appropriate text color based on the mode and BG value
+    func textColor(for bgValue: Double, staticColor: UIColor) -> UIColor {
+        switch self {
+        case .staticColor:
+            return staticColor
+        case .dynamic:
+            let highLine = Storage.shared.highLine.value
+            let lowLine = Storage.shared.lowLine.value
+
+            if bgValue >= highLine {
+                return .systemYellow
+            } else if bgValue <= lowLine {
+                return .systemRed
+            } else {
+                return .systemGreen
+            }
+        }
+    }
+}

+ 84 - 52
LoopFollow/Contact/ContactImageUpdater.swift

@@ -19,7 +19,15 @@ class ContactImageUpdater {
         return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
     }
 
-    func updateContactImage(bgValue: String, trend: String, delta: String, stale: Bool) {
+    private func textColor(for contactType: ContactType) -> UIColor {
+        guard contactType == .BG else { return savedTextUIColor }
+        let colorMode = Storage.shared.contactColorMode.value
+        // Use raw BG value in mg/dL (same units as highLine/lowLine)
+        let bgNumeric = Double(Observable.shared.bg.value ?? 0)
+        return colorMode.textColor(for: bgNumeric, staticColor: savedTextUIColor)
+    }
+
+    func updateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool) {
         queue.async {
             guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
                 LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
@@ -37,9 +45,17 @@ class ContactImageUpdater {
                     continue
                 }
 
+                if contactType == .IOB, Storage.shared.contactIOB.value != .separate {
+                    continue
+                }
+
                 let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
 
-                guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, stale: stale, contactType: contactType)?.pngData() else {
+                let includedFields = self.getIncludedFields(for: contactType)
+
+                let dynamicTextColor = self.textColor(for: contactType)
+
+                guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, iob: iob, stale: stale, contactType: contactType, includedFields: includedFields, textColor: dynamicTextColor)?.pngData() else {
                     LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
                     continue
                 }
@@ -100,7 +116,24 @@ class ContactImageUpdater {
         }
     }
 
-    private func generateContactImage(bgValue: String, trend: String, delta: String, stale: Bool, contactType: ContactType) -> UIImage? {
+    private func getIncludedFields(for contactType: ContactType) -> [ContactType] {
+        var included: [ContactType] = []
+        if Storage.shared.contactTrend.value == .include,
+           Storage.shared.contactTrendTarget.value == contactType {
+            included.append(.Trend)
+        }
+        if Storage.shared.contactDelta.value == .include,
+           Storage.shared.contactDeltaTarget.value == contactType {
+            included.append(.Delta)
+        }
+        if Storage.shared.contactIOB.value == .include,
+           Storage.shared.contactIOBTarget.value == contactType {
+            included.append(.IOB)
+        }
+        return included
+    }
+
+    private func generateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool, contactType: ContactType, includedFields: [ContactType], textColor: UIColor) -> UIImage? {
         let size = CGSize(width: 300, height: 300)
         UIGraphicsBeginImageContextWithOptions(size, false, 0)
         guard let context = UIGraphicsGetCurrentContext() else { return nil }
@@ -111,66 +144,65 @@ class ContactImageUpdater {
         let paragraphStyle = NSMutableParagraphStyle()
         paragraphStyle.alignment = .center
 
-        // Format extraDelta based on the user's unit preference
-        let unitPreference = Storage.shared.units.value
         let yOffset: CGFloat = 48
-        if contactType == .Trend, Storage.shared.contactTrend.value == .separate {
-            let trendRect = CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
-            let trendFontSize = max(40, 200 - CGFloat(trend.count * 15))
 
-            let trendAttributes: [NSAttributedString.Key: Any] = [
-                .font: UIFont.boldSystemFont(ofSize: trendFontSize),
-                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
-                .paragraphStyle: paragraphStyle,
-            ]
+        // Get the primary value for this contact type
+        let primaryValue: String
+        switch contactType {
+        case .BG: primaryValue = bgValue
+        case .Trend: primaryValue = trend
+        case .Delta: primaryValue = delta
+        case .IOB: primaryValue = iob
+        }
 
-            trend.draw(in: trendRect, withAttributes: trendAttributes)
-        } else if contactType == .Delta, Storage.shared.contactDelta.value == .separate {
-            let deltaRect = CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
-            let deltaFontSize = max(40, 200 - CGFloat(delta.count * 15))
+        // Build extra values from included fields
+        var extraValues: [String] = []
+        for field in includedFields {
+            switch field {
+            case .Trend: extraValues.append(trend)
+            case .Delta: extraValues.append(delta)
+            case .IOB: extraValues.append(iob)
+            case .BG: break
+            }
+        }
 
-            let deltaAttributes: [NSAttributedString.Key: Any] = [
-                .font: UIFont.boldSystemFont(ofSize: deltaFontSize),
-                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
-                .paragraphStyle: paragraphStyle,
-            ]
+        let hasExtras = !extraValues.isEmpty
 
-            delta.draw(in: deltaRect, withAttributes: deltaAttributes)
-        } else if contactType == .BG {
-            let includesExtra = Storage.shared.contactDelta.value == .include || Storage.shared.contactTrend.value == .include
+        // Determine font sizes based on number of extras
+        let maxFontSize: CGFloat = extraValues.count >= 2 ? 140 : (hasExtras ? 160 : 200)
+        let extraFontSize: CGFloat = extraValues.count >= 2 ? 60 : 90
 
-            let maxFontSize: CGFloat = includesExtra ? 160 : 200
-            let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
-            var bgAttributes: [NSAttributedString.Key: Any] = [
-                .font: UIFont.boldSystemFont(ofSize: fontSize),
-                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
-                .paragraphStyle: paragraphStyle,
-            ]
+        let fontSize = max(40, maxFontSize - CGFloat(primaryValue.count * 15))
 
-            if stale {
-                // Force background color back to black if stale
-                UIColor.black.setFill()
-                context.fill(CGRect(origin: .zero, size: size))
-                bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
-            }
+        let isBGStale = stale && contactType == .BG
 
-            let bgRect: CGRect = includesExtra
-                ? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
-                : CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
+        var primaryAttributes: [NSAttributedString.Key: Any] = [
+            .font: UIFont.boldSystemFont(ofSize: fontSize),
+            .foregroundColor: isBGStale ? UIColor.gray : textColor,
+            .paragraphStyle: paragraphStyle,
+        ]
 
-            bgValue.draw(in: bgRect, withAttributes: bgAttributes)
+        if isBGStale {
+            UIColor.black.setFill()
+            context.fill(CGRect(origin: .zero, size: size))
+            primaryAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+        }
 
-            if includesExtra {
-                let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
-                let extraAttributes: [NSAttributedString.Key: Any] = [
-                    .font: UIFont.systemFont(ofSize: 90),
-                    .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
-                    .paragraphStyle: paragraphStyle,
-                ]
+        let primaryRect: CGRect = hasExtras
+            ? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
+            : CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
 
-                let extra = Storage.shared.contactDelta.value == .include ? delta : trend
-                extra.draw(in: extraRect, withAttributes: extraAttributes)
-            }
+        primaryValue.draw(in: primaryRect, withAttributes: primaryAttributes)
+
+        if hasExtras {
+            let extraString = extraValues.joined(separator: " ")
+            let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
+            let extraAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.systemFont(ofSize: extraFontSize),
+                .foregroundColor: isBGStale ? UIColor.gray : textColor,
+                .paragraphStyle: paragraphStyle,
+            ]
+            extraString.draw(in: extraRect, withAttributes: extraAttributes)
         }
 
         let image = UIGraphicsGetImageFromCurrentImageContext()

+ 2 - 1
LoopFollow/Contact/ContactType.swift

@@ -1,8 +1,9 @@
 // LoopFollow
 // ContactType.swift
 
-enum ContactType: String, CaseIterable {
+enum ContactType: String, CaseIterable, Codable {
     case BG
     case Trend
     case Delta
+    case IOB
 }

+ 1 - 0
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -270,6 +270,7 @@ extension MainViewController {
                         bgValue: Observable.shared.bgText.value,
                         trend: Observable.shared.directionText.value,
                         delta: Observable.shared.deltaText.value,
+                        iob: Observable.shared.iobText.value,
                         stale: Observable.shared.bgStale.value
                     )
             }

+ 13 - 0
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -77,6 +77,7 @@ extension MainViewController {
 
     // NS Device Status Response Processor
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
+        let previousIOBText = Observable.shared.iobText.value
         infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
 
         // For Loop, clear the current override here - For Trio, it is handled using treatments
@@ -239,6 +240,18 @@ extension MainViewController {
         // Mark device status as loaded for initial loading state
         markDataLoaded("deviceStatus")
 
+        if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off,
+           Observable.shared.iobText.value != previousIOBText
+        {
+            contactImageUpdater.updateContactImage(
+                bgValue: Observable.shared.bgText.value,
+                trend: Observable.shared.directionText.value,
+                delta: Observable.shared.deltaText.value,
+                iob: Observable.shared.iobText.value,
+                stale: Observable.shared.bgStale.value
+            )
+        }
+
         LogManager.shared.log(category: .deviceStatus, message: "Update Device Status done", isDebug: true)
     }
 }

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

@@ -52,6 +52,7 @@ extension MainViewController {
             if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
                 infoManager.updateInfoData(type: .iob, value: insulinMetric)
                 latestIOB = insulinMetric
+                Observable.shared.iobText.value = insulinMetric.formattedValue()
             }
 
             // COB

+ 1 - 0
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -65,6 +65,7 @@ extension MainViewController {
             if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
                 infoManager.updateInfoData(type: .iob, value: iobMetric)
                 latestIOB = iobMetric
+                Observable.shared.iobText.value = iobMetric.formattedValue()
             }
 
             // COB

+ 5 - 0
LoopFollow/Metric/InsulinMetric.swift

@@ -17,4 +17,9 @@ class InsulinMetric: Metric {
         }
         super.init(value: value, maxFractionDigits: 2, minFractionDigits: 0)
     }
+
+    override func formattedValue() -> String {
+        let decimals = abs(value) >= 10 ? 0 : 1
+        return Localizer.formatToLocalizedString(value, maxFractionDigits: decimals, minFractionDigits: 0)
+    }
 }

+ 53 - 8
LoopFollow/Settings/ContactSettingsView.swift

@@ -31,31 +31,43 @@ struct ContactSettingsView: View {
 
                 if viewModel.contactEnabled {
                     Section(header: Text("Color Options")) {
-                        Text("Select the colors for your BG values.  Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.")
+                        Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.")
                             .font(.footnote)
                             .foregroundColor(.secondary)
                             .padding(.vertical, 4)
 
-                        Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) {
+                        Picker("Background Color", selection: $viewModel.contactBackgroundColor) {
                             ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
                                 Text(option.rawValue.capitalized).tag(option.rawValue)
                             }
                         }
 
-                        Picker("Select Text Color", selection: $viewModel.contactTextColor) {
-                            ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
-                                Text(option.rawValue.capitalized).tag(option.rawValue)
+                        Picker("Color Mode", selection: $viewModel.contactColorMode) {
+                            ForEach(ContactColorMode.allCases, id: \.self) { mode in
+                                Text(mode.displayName).tag(mode)
+                            }
+                        }
+
+                        if viewModel.contactColorMode == .staticColor {
+                            Picker("Text Color", selection: $viewModel.contactTextColor) {
+                                ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
+                                    Text(option.rawValue.capitalized).tag(option.rawValue)
+                                }
                             }
+                        } else {
+                            Text("Dynamic mode colors text based on BG range: Green (in range), Yellow (high), Red (low)")
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
                         }
                     }
 
                     Section(header: Text("Additional Information")) {
-                        Text("To see your trend or delta, include one in the original '\(viewModel.contactName)' contact, or create separate contacts ending in '- Trend' and '- Delta' for up to three contacts on your watch face.")
+                        Text("To see your trend, delta, or IOB, include them in another contact or create separate contacts. When using 'Include', select which contact to add the value to.")
                             .font(.footnote)
                             .foregroundColor(.secondary)
                             .padding(.vertical, 4)
 
-                        Text("Show Trend")
+                        Text("Trend")
                             .font(.subheadline)
                         Picker("Show Trend", selection: $viewModel.contactTrend) {
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
@@ -64,7 +76,15 @@ struct ContactSettingsView: View {
                         }
                         .pickerStyle(SegmentedPickerStyle())
 
-                        Text("Show Delta")
+                        if viewModel.contactTrend == .include {
+                            Picker("Include Trend in", selection: $viewModel.contactTrendTarget) {
+                                ForEach(viewModel.availableTargets(for: .Trend), id: \.self) { target in
+                                    Text(target.rawValue).tag(target)
+                                }
+                            }
+                        }
+
+                        Text("Delta")
                             .font(.subheadline)
                         Picker("Show Delta", selection: $viewModel.contactDelta) {
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
@@ -72,6 +92,31 @@ struct ContactSettingsView: View {
                             }
                         }
                         .pickerStyle(SegmentedPickerStyle())
+
+                        if viewModel.contactDelta == .include {
+                            Picker("Include Delta in", selection: $viewModel.contactDeltaTarget) {
+                                ForEach(viewModel.availableTargets(for: .Delta), id: \.self) { target in
+                                    Text(target.rawValue).tag(target)
+                                }
+                            }
+                        }
+
+                        Text("IOB")
+                            .font(.subheadline)
+                        Picker("Show IOB", selection: $viewModel.contactIOB) {
+                            ForEach(ContactIncludeOption.allCases, id: \.self) { option in
+                                Text(option.rawValue).tag(option)
+                            }
+                        }
+                        .pickerStyle(SegmentedPickerStyle())
+
+                        if viewModel.contactIOB == .include {
+                            Picker("Include IOB in", selection: $viewModel.contactIOBTarget) {
+                                ForEach(viewModel.availableTargets(for: .IOB), id: \.self) { target in
+                                    Text(target.rawValue).tag(target)
+                                }
+                            }
+                        }
                     }
                 }
             }

+ 90 - 6
LoopFollow/Settings/ContactSettingsViewModel.swift

@@ -24,20 +24,63 @@ class ContactSettingsViewModel: ObservableObject {
 
     @Published var contactTrend: ContactIncludeOption {
         didSet {
-            if contactTrend == .include && contactDelta == .include {
-                contactDelta = .off
-            }
             Storage.shared.contactTrend.value = contactTrend
+            if contactTrend != .include {
+                contactTrendTarget = .BG
+            }
+            if contactTrend != .separate {
+                if contactDeltaTarget == .Trend { contactDeltaTarget = .BG }
+                if contactIOBTarget == .Trend { contactIOBTarget = .BG }
+            }
             triggerRefresh()
         }
     }
 
     @Published var contactDelta: ContactIncludeOption {
         didSet {
-            if contactDelta == .include && contactTrend == .include {
-                contactTrend = .off
-            }
             Storage.shared.contactDelta.value = contactDelta
+            if contactDelta != .include {
+                contactDeltaTarget = .BG
+            }
+            if contactDelta != .separate {
+                if contactTrendTarget == .Delta { contactTrendTarget = .BG }
+                if contactIOBTarget == .Delta { contactIOBTarget = .BG }
+            }
+            triggerRefresh()
+        }
+    }
+
+    @Published var contactIOB: ContactIncludeOption {
+        didSet {
+            Storage.shared.contactIOB.value = contactIOB
+            if contactIOB != .include {
+                contactIOBTarget = .BG
+            }
+            if contactIOB != .separate {
+                if contactTrendTarget == .IOB { contactTrendTarget = .BG }
+                if contactDeltaTarget == .IOB { contactDeltaTarget = .BG }
+            }
+            triggerRefresh()
+        }
+    }
+
+    @Published var contactTrendTarget: ContactType {
+        didSet {
+            Storage.shared.contactTrendTarget.value = contactTrendTarget
+            triggerRefresh()
+        }
+    }
+
+    @Published var contactDeltaTarget: ContactType {
+        didSet {
+            Storage.shared.contactDeltaTarget.value = contactDeltaTarget
+            triggerRefresh()
+        }
+    }
+
+    @Published var contactIOBTarget: ContactType {
+        didSet {
+            Storage.shared.contactIOBTarget.value = contactIOBTarget
             triggerRefresh()
         }
     }
@@ -56,6 +99,13 @@ class ContactSettingsViewModel: ObservableObject {
         }
     }
 
+    @Published var contactColorMode: ContactColorMode {
+        didSet {
+            Storage.shared.contactColorMode.value = contactColorMode
+            triggerRefresh()
+        }
+    }
+
     private let storage = ObservableUserDefaults.shared
     private var cancellables = Set<AnyCancellable>()
 
@@ -63,8 +113,13 @@ class ContactSettingsViewModel: ObservableObject {
         contactEnabled = Storage.shared.contactEnabled.value
         contactTrend = Storage.shared.contactTrend.value
         contactDelta = Storage.shared.contactDelta.value
+        contactIOB = Storage.shared.contactIOB.value
+        contactTrendTarget = Storage.shared.contactTrendTarget.value
+        contactDeltaTarget = Storage.shared.contactDeltaTarget.value
+        contactIOBTarget = Storage.shared.contactIOBTarget.value
         contactBackgroundColor = Storage.shared.contactBackgroundColor.value
         contactTextColor = Storage.shared.contactTextColor.value
+        contactColorMode = Storage.shared.contactColorMode.value
 
         Storage.shared.contactEnabled.$value
             .assign(to: &$contactEnabled)
@@ -75,11 +130,40 @@ class ContactSettingsViewModel: ObservableObject {
         Storage.shared.contactDelta.$value
             .assign(to: &$contactDelta)
 
+        Storage.shared.contactIOB.$value
+            .assign(to: &$contactIOB)
+
+        Storage.shared.contactTrendTarget.$value
+            .assign(to: &$contactTrendTarget)
+
+        Storage.shared.contactDeltaTarget.$value
+            .assign(to: &$contactDeltaTarget)
+
+        Storage.shared.contactIOBTarget.$value
+            .assign(to: &$contactIOBTarget)
+
         Storage.shared.contactBackgroundColor.$value
             .assign(to: &$contactBackgroundColor)
 
         Storage.shared.contactTextColor.$value
             .assign(to: &$contactTextColor)
+
+        Storage.shared.contactColorMode.$value
+            .assign(to: &$contactColorMode)
+    }
+
+    func availableTargets(for field: ContactType) -> [ContactType] {
+        var targets: [ContactType] = [.BG]
+        if field != .Trend, contactTrend == .separate {
+            targets.append(.Trend)
+        }
+        if field != .Delta, contactDelta == .separate {
+            targets.append(.Delta)
+        }
+        if field != .IOB, contactIOB == .separate {
+            targets.append(.IOB)
+        }
+        return targets
     }
 
     private func triggerRefresh() {

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -22,6 +22,7 @@ class Observable {
     var bgTextColor = ObservableValue<Color>(default: .primary)
     var directionText = ObservableValue<String>(default: "-")
     var deltaText = ObservableValue<String>(default: "+0")
+    var iobText = ObservableValue<String>(default: "--")
 
     var currentAlarm = ObservableValue<UUID?>(default: nil)
     var alarmSoundPlaying = ObservableValue<Bool>(default: false)

+ 5 - 0
LoopFollow/Storage/Storage.swift

@@ -43,9 +43,14 @@ class Storage {
 
     var contactTrend = StorageValue<ContactIncludeOption>(key: "contactTrend", defaultValue: .off)
     var contactDelta = StorageValue<ContactIncludeOption>(key: "contactDelta", defaultValue: .off)
+    var contactIOB = StorageValue<ContactIncludeOption>(key: "contactIOB", defaultValue: .off)
+    var contactTrendTarget = StorageValue<ContactType>(key: "contactTrendTarget", defaultValue: .BG)
+    var contactDeltaTarget = StorageValue<ContactType>(key: "contactDeltaTarget", defaultValue: .BG)
+    var contactIOBTarget = StorageValue<ContactType>(key: "contactIOBTarget", defaultValue: .BG)
     var contactEnabled = StorageValue<Bool>(key: "contactEnabled", defaultValue: false)
     var contactBackgroundColor = StorageValue<String>(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue)
     var contactTextColor = StorageValue<String>(key: "contactTextColor", defaultValue: ContactColorOption.white.rawValue)
+    var contactColorMode = StorageValue<ContactColorMode>(key: "contactColorMode", defaultValue: .staticColor)
 
     var sensorScheduleOffset = StorageValue<Double?>(key: "sensorScheduleOffset", defaultValue: nil)