// LoopFollow // ContactImageUpdater.swift import Contacts import Foundation import UIKit class ContactImageUpdater { private let contactStore = CNContactStore() private let queue = DispatchQueue(label: "ContactImageUpdaterQueue") private var savedBackgroundUIColor: UIColor { let rawValue = Storage.shared.contactBackgroundColor.value return ContactColorOption(rawValue: rawValue)?.uiColor ?? .black } private var savedTextUIColor: UIColor { let rawValue = Storage.shared.contactTextColor.value return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white } 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.") return } let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow" for contactType in ContactType.allCases { if contactType == .Delta, Storage.shared.contactDelta.value != .separate { continue } if contactType == .Trend, Storage.shared.contactTrend.value != .separate { continue } if contactType == .IOB, Storage.shared.contactIOB.value != .separate { continue } let contactName = "\(bundleDisplayName) - \(contactType.rawValue)" 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 } do { let keysToFetch = [ CNContactIdentifierKey as CNKeyDescriptor, CNContactGivenNameKey as CNKeyDescriptor, CNContactImageDataKey as CNKeyDescriptor, ] var allMatchingContacts: [CNContact] = [] let containers = try self.contactStore.containers(matching: nil) // Run fast check first let namePredicate = CNContact.predicateForContacts(matchingName: contactName) let nameContacts = try self.contactStore.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) let matchingNameContacts = nameContacts.filter { $0.givenName == contactName } allMatchingContacts.append(contentsOf: matchingNameContacts) // If it fails, make heavy iteration by containers if allMatchingContacts.isEmpty { for container in containers { let containerPredicate = CNContact.predicateForContactsInContainer(withIdentifier: container.identifier) let containerContacts = try self.contactStore.unifiedContacts(matching: containerPredicate, keysToFetch: keysToFetch) let matchingContacts = containerContacts.filter { $0.givenName == contactName } for contact in matchingContacts { if !allMatchingContacts.contains(where: { $0.identifier == contact.identifier }) { allMatchingContacts.append(contact) } } } } let saveRequest = CNSaveRequest() if let existingContact = allMatchingContacts.first { if let mutableContact = existingContact.mutableCopy() as? CNMutableContact { mutableContact.imageData = imageData saveRequest.update(mutableContact) try self.contactStore.execute(saveRequest) LogManager.shared.log(category: .contact, message: "Contact image updated successfully for \(contactName).") } } else { // Use default container let defaultContainer = self.contactStore.defaultContainerIdentifier() let newContact = CNMutableContact() newContact.givenName = contactName newContact.imageData = imageData saveRequest.add(newContact, toContainerWithIdentifier: defaultContainer) try self.contactStore.execute(saveRequest) LogManager.shared.log(category: .contact, message: "New contact created with updated image for \(contactName).") } } catch { LogManager.shared.log(category: .contact, message: "Failed to update or create contact for \(contactName): \(error)") } } } } 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 } savedBackgroundUIColor.setFill() context.fill(CGRect(origin: .zero, size: size)) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center let yOffset: CGFloat = 48 // 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 } // 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 hasExtras = !extraValues.isEmpty // 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 fontSize = max(40, maxFontSize - CGFloat(primaryValue.count * 15)) let isBGStale = stale && contactType == .BG var primaryAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: fontSize), .foregroundColor: isBGStale ? UIColor.gray : textColor, .paragraphStyle: paragraphStyle, ] if isBGStale { UIColor.black.setFill() context.fill(CGRect(origin: .zero, size: size)) primaryAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } 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) 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() UIGraphicsEndImageContext() return image } }