Sfoglia il codice sorgente

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

Ľubor Jurena 3 mesi fa
parent
commit
ebba6882d4

+ 4 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -173,6 +173,7 @@
 		DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
 		DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
 		DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
 		DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
 		DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.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 */; };
 		DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; };
 		DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
 		DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
 		DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
@@ -1029,6 +1031,7 @@
 				DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
 				DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
 				DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
 				DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
 				DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
 				DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
+				DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */,
 				DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
 				DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
 			);
 			);
 			path = Contact;
 			path = Contact;
@@ -2062,6 +2065,7 @@
 				DDC7E5462DBD8A1600EB1127 /* LowBgAlarmEditor.swift in Sources */,
 				DDC7E5462DBD8A1600EB1127 /* LowBgAlarmEditor.swift in Sources */,
 				DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */,
 				DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */,
 				DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
 				DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
+				DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */,
 				DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */,
 				DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */,
 				DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
 				DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
 				FCEF87AC24A141A700AE6FA0 /* Localizer.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
         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 {
         queue.async {
             guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
             guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
                 LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
                 LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
@@ -37,9 +45,17 @@ class ContactImageUpdater {
                     continue
                     continue
                 }
                 }
 
 
+                if contactType == .IOB, Storage.shared.contactIOB.value != .separate {
+                    continue
+                }
+
                 let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
                 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).")
                     LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
                     continue
                     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)
         let size = CGSize(width: 300, height: 300)
         UIGraphicsBeginImageContextWithOptions(size, false, 0)
         UIGraphicsBeginImageContextWithOptions(size, false, 0)
         guard let context = UIGraphicsGetCurrentContext() else { return nil }
         guard let context = UIGraphicsGetCurrentContext() else { return nil }
@@ -111,66 +144,65 @@ class ContactImageUpdater {
         let paragraphStyle = NSMutableParagraphStyle()
         let paragraphStyle = NSMutableParagraphStyle()
         paragraphStyle.alignment = .center
         paragraphStyle.alignment = .center
 
 
-        // Format extraDelta based on the user's unit preference
-        let unitPreference = Storage.shared.units.value
         let yOffset: CGFloat = 48
         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()
         let image = UIGraphicsGetImageFromCurrentImageContext()

+ 2 - 1
LoopFollow/Contact/ContactType.swift

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

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

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

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

@@ -77,6 +77,7 @@ extension MainViewController {
 
 
     // NS Device Status Response Processor
     // NS Device Status Response Processor
     func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
     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])
         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
         // 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
         // Mark device status as loaded for initial loading state
         markDataLoaded("deviceStatus")
         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)
         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") {
             if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
                 infoManager.updateInfoData(type: .iob, value: insulinMetric)
                 infoManager.updateInfoData(type: .iob, value: insulinMetric)
                 latestIOB = insulinMetric
                 latestIOB = insulinMetric
+                Observable.shared.iobText.value = insulinMetric.formattedValue()
             }
             }
 
 
             // COB
             // COB

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

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

+ 5 - 0
LoopFollow/Metric/InsulinMetric.swift

@@ -17,4 +17,9 @@ class InsulinMetric: Metric {
         }
         }
         super.init(value: value, maxFractionDigits: 2, minFractionDigits: 0)
         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 {
                 if viewModel.contactEnabled {
                     Section(header: Text("Color Options")) {
                     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)
                             .font(.footnote)
                             .foregroundColor(.secondary)
                             .foregroundColor(.secondary)
                             .padding(.vertical, 4)
                             .padding(.vertical, 4)
 
 
-                        Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) {
+                        Picker("Background Color", selection: $viewModel.contactBackgroundColor) {
                             ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
                             ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
                                 Text(option.rawValue.capitalized).tag(option.rawValue)
                                 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")) {
                     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)
                             .font(.footnote)
                             .foregroundColor(.secondary)
                             .foregroundColor(.secondary)
                             .padding(.vertical, 4)
                             .padding(.vertical, 4)
 
 
-                        Text("Show Trend")
+                        Text("Trend")
                             .font(.subheadline)
                             .font(.subheadline)
                         Picker("Show Trend", selection: $viewModel.contactTrend) {
                         Picker("Show Trend", selection: $viewModel.contactTrend) {
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
@@ -64,7 +76,15 @@ struct ContactSettingsView: View {
                         }
                         }
                         .pickerStyle(SegmentedPickerStyle())
                         .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)
                             .font(.subheadline)
                         Picker("Show Delta", selection: $viewModel.contactDelta) {
                         Picker("Show Delta", selection: $viewModel.contactDelta) {
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
                             ForEach(ContactIncludeOption.allCases, id: \.self) { option in
@@ -72,6 +92,31 @@ struct ContactSettingsView: View {
                             }
                             }
                         }
                         }
                         .pickerStyle(SegmentedPickerStyle())
                         .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 {
     @Published var contactTrend: ContactIncludeOption {
         didSet {
         didSet {
-            if contactTrend == .include && contactDelta == .include {
-                contactDelta = .off
-            }
             Storage.shared.contactTrend.value = contactTrend
             Storage.shared.contactTrend.value = contactTrend
+            if contactTrend != .include {
+                contactTrendTarget = .BG
+            }
+            if contactTrend != .separate {
+                if contactDeltaTarget == .Trend { contactDeltaTarget = .BG }
+                if contactIOBTarget == .Trend { contactIOBTarget = .BG }
+            }
             triggerRefresh()
             triggerRefresh()
         }
         }
     }
     }
 
 
     @Published var contactDelta: ContactIncludeOption {
     @Published var contactDelta: ContactIncludeOption {
         didSet {
         didSet {
-            if contactDelta == .include && contactTrend == .include {
-                contactTrend = .off
-            }
             Storage.shared.contactDelta.value = contactDelta
             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()
             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 let storage = ObservableUserDefaults.shared
     private var cancellables = Set<AnyCancellable>()
     private var cancellables = Set<AnyCancellable>()
 
 
@@ -63,8 +113,13 @@ class ContactSettingsViewModel: ObservableObject {
         contactEnabled = Storage.shared.contactEnabled.value
         contactEnabled = Storage.shared.contactEnabled.value
         contactTrend = Storage.shared.contactTrend.value
         contactTrend = Storage.shared.contactTrend.value
         contactDelta = Storage.shared.contactDelta.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
         contactBackgroundColor = Storage.shared.contactBackgroundColor.value
         contactTextColor = Storage.shared.contactTextColor.value
         contactTextColor = Storage.shared.contactTextColor.value
+        contactColorMode = Storage.shared.contactColorMode.value
 
 
         Storage.shared.contactEnabled.$value
         Storage.shared.contactEnabled.$value
             .assign(to: &$contactEnabled)
             .assign(to: &$contactEnabled)
@@ -75,11 +130,40 @@ class ContactSettingsViewModel: ObservableObject {
         Storage.shared.contactDelta.$value
         Storage.shared.contactDelta.$value
             .assign(to: &$contactDelta)
             .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
         Storage.shared.contactBackgroundColor.$value
             .assign(to: &$contactBackgroundColor)
             .assign(to: &$contactBackgroundColor)
 
 
         Storage.shared.contactTextColor.$value
         Storage.shared.contactTextColor.$value
             .assign(to: &$contactTextColor)
             .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() {
     private func triggerRefresh() {

+ 1 - 0
LoopFollow/Storage/Observable.swift

@@ -22,6 +22,7 @@ class Observable {
     var bgTextColor = ObservableValue<Color>(default: .primary)
     var bgTextColor = ObservableValue<Color>(default: .primary)
     var directionText = ObservableValue<String>(default: "-")
     var directionText = ObservableValue<String>(default: "-")
     var deltaText = ObservableValue<String>(default: "+0")
     var deltaText = ObservableValue<String>(default: "+0")
+    var iobText = ObservableValue<String>(default: "--")
 
 
     var currentAlarm = ObservableValue<UUID?>(default: nil)
     var currentAlarm = ObservableValue<UUID?>(default: nil)
     var alarmSoundPlaying = ObservableValue<Bool>(default: false)
     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 contactTrend = StorageValue<ContactIncludeOption>(key: "contactTrend", defaultValue: .off)
     var contactDelta = StorageValue<ContactIncludeOption>(key: "contactDelta", 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 contactEnabled = StorageValue<Bool>(key: "contactEnabled", defaultValue: false)
     var contactBackgroundColor = StorageValue<String>(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue)
     var contactBackgroundColor = StorageValue<String>(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue)
     var contactTextColor = StorageValue<String>(key: "contactTextColor", defaultValue: ContactColorOption.white.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)
     var sensorScheduleOffset = StorageValue<Double?>(key: "sensorScheduleOffset", defaultValue: nil)