Skip to content

Commit ebba688

Browse files
authored
Add IOB display option for contact watch face (#512)
1 parent 57e0888 commit ebba688

File tree

13 files changed

+297
-67
lines changed

13 files changed

+297
-67
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
174174
DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
175175
DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; };
176+
DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */; };
176177
DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; };
177178
DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
178179
DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; };
@@ -580,6 +581,7 @@
580581
DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
581582
DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
582583
DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = "<group>"; };
584+
DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorMode.swift; sourceTree = "<group>"; };
583585
DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactIncludeOption.swift; sourceTree = "<group>"; };
584586
DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactType.swift; sourceTree = "<group>"; };
585587
DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
@@ -1029,6 +1031,7 @@
10291031
DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
10301032
DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
10311033
DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
1034+
DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */,
10321035
DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
10331036
);
10341037
path = Contact;
@@ -2062,6 +2065,7 @@
20622065
DDC7E5462DBD8A1600EB1127 /* LowBgAlarmEditor.swift in Sources */,
20632066
DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */,
20642067
DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
2068+
DDA9ACA62D6A66D000E6F1A9 /* ContactColorMode.swift in Sources */,
20652069
DDC7E5382DBD887400EB1127 /* isOnPhoneCall.swift in Sources */,
20662070
DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
20672071
FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// LoopFollow
2+
// ContactColorMode.swift
3+
4+
import UIKit
5+
6+
enum ContactColorMode: String, Codable, CaseIterable {
7+
case staticColor = "Static"
8+
case dynamic = "Dynamic"
9+
10+
var displayName: String {
11+
switch self {
12+
case .staticColor:
13+
return "Static"
14+
case .dynamic:
15+
return "Dynamic (BG Range)"
16+
}
17+
}
18+
19+
/// Returns the appropriate text color based on the mode and BG value
20+
func textColor(for bgValue: Double, staticColor: UIColor) -> UIColor {
21+
switch self {
22+
case .staticColor:
23+
return staticColor
24+
case .dynamic:
25+
let highLine = Storage.shared.highLine.value
26+
let lowLine = Storage.shared.lowLine.value
27+
28+
if bgValue >= highLine {
29+
return .systemYellow
30+
} else if bgValue <= lowLine {
31+
return .systemRed
32+
} else {
33+
return .systemGreen
34+
}
35+
}
36+
}
37+
}

LoopFollow/Contact/ContactImageUpdater.swift

Lines changed: 84 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ class ContactImageUpdater {
1919
return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
2020
}
2121

22-
func updateContactImage(bgValue: String, trend: String, delta: String, stale: Bool) {
22+
private func textColor(for contactType: ContactType) -> UIColor {
23+
guard contactType == .BG else { return savedTextUIColor }
24+
let colorMode = Storage.shared.contactColorMode.value
25+
// Use raw BG value in mg/dL (same units as highLine/lowLine)
26+
let bgNumeric = Double(Observable.shared.bg.value ?? 0)
27+
return colorMode.textColor(for: bgNumeric, staticColor: savedTextUIColor)
28+
}
29+
30+
func updateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool) {
2331
queue.async {
2432
guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
2533
LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
@@ -37,9 +45,17 @@ class ContactImageUpdater {
3745
continue
3846
}
3947

48+
if contactType == .IOB, Storage.shared.contactIOB.value != .separate {
49+
continue
50+
}
51+
4052
let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
4153

42-
guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, stale: stale, contactType: contactType)?.pngData() else {
54+
let includedFields = self.getIncludedFields(for: contactType)
55+
56+
let dynamicTextColor = self.textColor(for: contactType)
57+
58+
guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, iob: iob, stale: stale, contactType: contactType, includedFields: includedFields, textColor: dynamicTextColor)?.pngData() else {
4359
LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
4460
continue
4561
}
@@ -100,7 +116,24 @@ class ContactImageUpdater {
100116
}
101117
}
102118

103-
private func generateContactImage(bgValue: String, trend: String, delta: String, stale: Bool, contactType: ContactType) -> UIImage? {
119+
private func getIncludedFields(for contactType: ContactType) -> [ContactType] {
120+
var included: [ContactType] = []
121+
if Storage.shared.contactTrend.value == .include,
122+
Storage.shared.contactTrendTarget.value == contactType {
123+
included.append(.Trend)
124+
}
125+
if Storage.shared.contactDelta.value == .include,
126+
Storage.shared.contactDeltaTarget.value == contactType {
127+
included.append(.Delta)
128+
}
129+
if Storage.shared.contactIOB.value == .include,
130+
Storage.shared.contactIOBTarget.value == contactType {
131+
included.append(.IOB)
132+
}
133+
return included
134+
}
135+
136+
private func generateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool, contactType: ContactType, includedFields: [ContactType], textColor: UIColor) -> UIImage? {
104137
let size = CGSize(width: 300, height: 300)
105138
UIGraphicsBeginImageContextWithOptions(size, false, 0)
106139
guard let context = UIGraphicsGetCurrentContext() else { return nil }
@@ -111,66 +144,65 @@ class ContactImageUpdater {
111144
let paragraphStyle = NSMutableParagraphStyle()
112145
paragraphStyle.alignment = .center
113146

114-
// Format extraDelta based on the user's unit preference
115-
let unitPreference = Storage.shared.units.value
116147
let yOffset: CGFloat = 48
117-
if contactType == .Trend, Storage.shared.contactTrend.value == .separate {
118-
let trendRect = CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
119-
let trendFontSize = max(40, 200 - CGFloat(trend.count * 15))
120148

121-
let trendAttributes: [NSAttributedString.Key: Any] = [
122-
.font: UIFont.boldSystemFont(ofSize: trendFontSize),
123-
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
124-
.paragraphStyle: paragraphStyle,
125-
]
149+
// Get the primary value for this contact type
150+
let primaryValue: String
151+
switch contactType {
152+
case .BG: primaryValue = bgValue
153+
case .Trend: primaryValue = trend
154+
case .Delta: primaryValue = delta
155+
case .IOB: primaryValue = iob
156+
}
126157

127-
trend.draw(in: trendRect, withAttributes: trendAttributes)
128-
} else if contactType == .Delta, Storage.shared.contactDelta.value == .separate {
129-
let deltaRect = CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
130-
let deltaFontSize = max(40, 200 - CGFloat(delta.count * 15))
158+
// Build extra values from included fields
159+
var extraValues: [String] = []
160+
for field in includedFields {
161+
switch field {
162+
case .Trend: extraValues.append(trend)
163+
case .Delta: extraValues.append(delta)
164+
case .IOB: extraValues.append(iob)
165+
case .BG: break
166+
}
167+
}
131168

132-
let deltaAttributes: [NSAttributedString.Key: Any] = [
133-
.font: UIFont.boldSystemFont(ofSize: deltaFontSize),
134-
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
135-
.paragraphStyle: paragraphStyle,
136-
]
169+
let hasExtras = !extraValues.isEmpty
137170

138-
delta.draw(in: deltaRect, withAttributes: deltaAttributes)
139-
} else if contactType == .BG {
140-
let includesExtra = Storage.shared.contactDelta.value == .include || Storage.shared.contactTrend.value == .include
171+
// Determine font sizes based on number of extras
172+
let maxFontSize: CGFloat = extraValues.count >= 2 ? 140 : (hasExtras ? 160 : 200)
173+
let extraFontSize: CGFloat = extraValues.count >= 2 ? 60 : 90
141174

142-
let maxFontSize: CGFloat = includesExtra ? 160 : 200
143-
let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
144-
var bgAttributes: [NSAttributedString.Key: Any] = [
145-
.font: UIFont.boldSystemFont(ofSize: fontSize),
146-
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
147-
.paragraphStyle: paragraphStyle,
148-
]
175+
let fontSize = max(40, maxFontSize - CGFloat(primaryValue.count * 15))
149176

150-
if stale {
151-
// Force background color back to black if stale
152-
UIColor.black.setFill()
153-
context.fill(CGRect(origin: .zero, size: size))
154-
bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
155-
}
177+
let isBGStale = stale && contactType == .BG
156178

157-
let bgRect: CGRect = includesExtra
158-
? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
159-
: CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
179+
var primaryAttributes: [NSAttributedString.Key: Any] = [
180+
.font: UIFont.boldSystemFont(ofSize: fontSize),
181+
.foregroundColor: isBGStale ? UIColor.gray : textColor,
182+
.paragraphStyle: paragraphStyle,
183+
]
160184

161-
bgValue.draw(in: bgRect, withAttributes: bgAttributes)
185+
if isBGStale {
186+
UIColor.black.setFill()
187+
context.fill(CGRect(origin: .zero, size: size))
188+
primaryAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
189+
}
162190

163-
if includesExtra {
164-
let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
165-
let extraAttributes: [NSAttributedString.Key: Any] = [
166-
.font: UIFont.systemFont(ofSize: 90),
167-
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
168-
.paragraphStyle: paragraphStyle,
169-
]
191+
let primaryRect: CGRect = hasExtras
192+
? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
193+
: CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
170194

171-
let extra = Storage.shared.contactDelta.value == .include ? delta : trend
172-
extra.draw(in: extraRect, withAttributes: extraAttributes)
173-
}
195+
primaryValue.draw(in: primaryRect, withAttributes: primaryAttributes)
196+
197+
if hasExtras {
198+
let extraString = extraValues.joined(separator: " ")
199+
let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
200+
let extraAttributes: [NSAttributedString.Key: Any] = [
201+
.font: UIFont.systemFont(ofSize: extraFontSize),
202+
.foregroundColor: isBGStale ? UIColor.gray : textColor,
203+
.paragraphStyle: paragraphStyle,
204+
]
205+
extraString.draw(in: extraRect, withAttributes: extraAttributes)
174206
}
175207

176208
let image = UIGraphicsGetImageFromCurrentImageContext()
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// LoopFollow
22
// ContactType.swift
33

4-
enum ContactType: String, CaseIterable {
4+
enum ContactType: String, CaseIterable, Codable {
55
case BG
66
case Trend
77
case Delta
8+
case IOB
89
}

LoopFollow/Controllers/Nightscout/BGData.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ extension MainViewController {
270270
bgValue: Observable.shared.bgText.value,
271271
trend: Observable.shared.directionText.value,
272272
delta: Observable.shared.deltaText.value,
273+
iob: Observable.shared.iobText.value,
273274
stale: Observable.shared.bgStale.value
274275
)
275276
}

LoopFollow/Controllers/Nightscout/DeviceStatus.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ extension MainViewController {
7777

7878
// NS Device Status Response Processor
7979
func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
80+
let previousIOBText = Observable.shared.iobText.value
8081
infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
8182

8283
// For Loop, clear the current override here - For Trio, it is handled using treatments
@@ -239,6 +240,18 @@ extension MainViewController {
239240
// Mark device status as loaded for initial loading state
240241
markDataLoaded("deviceStatus")
241242

243+
if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off,
244+
Observable.shared.iobText.value != previousIOBText
245+
{
246+
contactImageUpdater.updateContactImage(
247+
bgValue: Observable.shared.bgText.value,
248+
trend: Observable.shared.directionText.value,
249+
delta: Observable.shared.deltaText.value,
250+
iob: Observable.shared.iobText.value,
251+
stale: Observable.shared.bgStale.value
252+
)
253+
}
254+
242255
LogManager.shared.log(category: .deviceStatus, message: "Update Device Status done", isDebug: true)
243256
}
244257
}

LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ extension MainViewController {
5252
if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
5353
infoManager.updateInfoData(type: .iob, value: insulinMetric)
5454
latestIOB = insulinMetric
55+
Observable.shared.iobText.value = insulinMetric.formattedValue()
5556
}
5657

5758
// COB

LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ extension MainViewController {
6565
if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") {
6666
infoManager.updateInfoData(type: .iob, value: iobMetric)
6767
latestIOB = iobMetric
68+
Observable.shared.iobText.value = iobMetric.formattedValue()
6869
}
6970

7071
// COB

LoopFollow/Metric/InsulinMetric.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,9 @@ class InsulinMetric: Metric {
1717
}
1818
super.init(value: value, maxFractionDigits: 2, minFractionDigits: 0)
1919
}
20+
21+
override func formattedValue() -> String {
22+
let decimals = abs(value) >= 10 ? 0 : 1
23+
return Localizer.formatToLocalizedString(value, maxFractionDigits: decimals, minFractionDigits: 0)
24+
}
2025
}

0 commit comments

Comments
 (0)