Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,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 */; };
Expand Down Expand Up @@ -570,6 +571,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>"; };
Expand Down Expand Up @@ -1015,6 +1017,7 @@
DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
DDA9ACA52D6A66C800E6F1A9 /* ContactColorMode.swift */,
DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
);
path = Contact;
Expand Down Expand Up @@ -2036,6 +2039,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 */,
Expand Down
46 changes: 46 additions & 0 deletions LoopFollow/Contact/ContactColorMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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
}
}
}

/// Parses a BG string value to Double, handling locale-specific decimal separators
static func parseBGValue(_ bgString: String) -> Double {
// Replace comma with period to handle European locales
let normalized = bgString.replacingOccurrences(of: ",", with: ".")
// Keep only numbers and decimal point
let numericString = normalized.filter { $0.isNumber || $0 == "." }
return Double(numericString) ?? 0
}
}
133 changes: 81 additions & 52 deletions LoopFollow/Contact/ContactImageUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ class ContactImageUpdater {
return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
}

func updateContactImage(bgValue: String, trend: String, delta: String, stale: Bool) {
private func textColor() -> UIColor {
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.")
Expand All @@ -37,9 +44,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()

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
}
Expand Down Expand Up @@ -100,7 +115,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 }
Expand All @@ -111,66 +143,63 @@ 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
}
var primaryAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: fontSize),
.foregroundColor: stale ? UIColor.gray : textColor,
.paragraphStyle: paragraphStyle,
]

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)
if stale {
UIColor.black.setFill()
context.fill(CGRect(origin: .zero, size: size))
primaryAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
}

bgValue.draw(in: bgRect, withAttributes: bgAttributes)
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)

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,
]
primaryValue.draw(in: primaryRect, withAttributes: primaryAttributes)

let extra = Storage.shared.contactDelta.value == .include ? delta : trend
extra.draw(in: extraRect, withAttributes: extraAttributes)
}
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: stale ? UIColor.gray : textColor,
.paragraphStyle: paragraphStyle,
]
extraString.draw(in: extraRect, withAttributes: extraAttributes)
}

let image = UIGraphicsGetImageFromCurrentImageContext()
Expand Down
3 changes: 2 additions & 1 deletion LoopFollow/Contact/ContactType.swift
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ extension MainViewController {
// Mark device status as loaded for initial loading state
markDataLoaded("deviceStatus")

if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off {
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 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading