ContactImageUpdater.swift 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. // LoopFollow
  2. // ContactImageUpdater.swift
  3. import Contacts
  4. import Foundation
  5. import UIKit
  6. class ContactImageUpdater {
  7. private let contactStore = CNContactStore()
  8. private let queue = DispatchQueue(label: "ContactImageUpdaterQueue")
  9. private var savedBackgroundUIColor: UIColor {
  10. let rawValue = Storage.shared.contactBackgroundColor.value
  11. return ContactColorOption(rawValue: rawValue)?.uiColor ?? .black
  12. }
  13. private var savedTextUIColor: UIColor {
  14. let rawValue = Storage.shared.contactTextColor.value
  15. return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
  16. }
  17. private func textColor(for contactType: ContactType) -> UIColor {
  18. guard contactType == .BG else { return savedTextUIColor }
  19. let colorMode = Storage.shared.contactColorMode.value
  20. // Use raw BG value in mg/dL (same units as highLine/lowLine)
  21. let bgNumeric = Double(Observable.shared.bg.value ?? 0)
  22. return colorMode.textColor(for: bgNumeric, staticColor: savedTextUIColor)
  23. }
  24. func updateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool) {
  25. queue.async {
  26. guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
  27. LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
  28. return
  29. }
  30. let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow"
  31. for contactType in ContactType.allCases {
  32. if contactType == .Delta, Storage.shared.contactDelta.value != .separate {
  33. continue
  34. }
  35. if contactType == .Trend, Storage.shared.contactTrend.value != .separate {
  36. continue
  37. }
  38. if contactType == .IOB, Storage.shared.contactIOB.value != .separate {
  39. continue
  40. }
  41. let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
  42. let includedFields = self.getIncludedFields(for: contactType)
  43. let dynamicTextColor = self.textColor(for: contactType)
  44. guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, iob: iob, stale: stale, contactType: contactType, includedFields: includedFields, textColor: dynamicTextColor)?.pngData() else {
  45. LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
  46. continue
  47. }
  48. do {
  49. let keysToFetch = [
  50. CNContactIdentifierKey as CNKeyDescriptor,
  51. CNContactGivenNameKey as CNKeyDescriptor,
  52. CNContactImageDataKey as CNKeyDescriptor,
  53. ]
  54. var allMatchingContacts: [CNContact] = []
  55. let containers = try self.contactStore.containers(matching: nil)
  56. // Run fast check first
  57. let namePredicate = CNContact.predicateForContacts(matchingName: contactName)
  58. let nameContacts = try self.contactStore.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch)
  59. let matchingNameContacts = nameContacts.filter { $0.givenName == contactName }
  60. allMatchingContacts.append(contentsOf: matchingNameContacts)
  61. // If it fails, make heavy iteration by containers
  62. if allMatchingContacts.isEmpty {
  63. for container in containers {
  64. let containerPredicate = CNContact.predicateForContactsInContainer(withIdentifier: container.identifier)
  65. let containerContacts = try self.contactStore.unifiedContacts(matching: containerPredicate, keysToFetch: keysToFetch)
  66. let matchingContacts = containerContacts.filter { $0.givenName == contactName }
  67. for contact in matchingContacts {
  68. if !allMatchingContacts.contains(where: { $0.identifier == contact.identifier }) {
  69. allMatchingContacts.append(contact)
  70. }
  71. }
  72. }
  73. }
  74. let saveRequest = CNSaveRequest()
  75. if let existingContact = allMatchingContacts.first {
  76. if let mutableContact = existingContact.mutableCopy() as? CNMutableContact {
  77. mutableContact.imageData = imageData
  78. saveRequest.update(mutableContact)
  79. try self.contactStore.execute(saveRequest)
  80. LogManager.shared.log(category: .contact, message: "Contact image updated successfully for \(contactName).")
  81. }
  82. } else {
  83. // Use default container
  84. let defaultContainer = self.contactStore.defaultContainerIdentifier()
  85. let newContact = CNMutableContact()
  86. newContact.givenName = contactName
  87. newContact.imageData = imageData
  88. saveRequest.add(newContact, toContainerWithIdentifier: defaultContainer)
  89. try self.contactStore.execute(saveRequest)
  90. LogManager.shared.log(category: .contact, message: "New contact created with updated image for \(contactName).")
  91. }
  92. } catch {
  93. LogManager.shared.log(category: .contact, message: "Failed to update or create contact for \(contactName): \(error)")
  94. }
  95. }
  96. }
  97. }
  98. private func getIncludedFields(for contactType: ContactType) -> [ContactType] {
  99. var included: [ContactType] = []
  100. if Storage.shared.contactTrend.value == .include,
  101. Storage.shared.contactTrendTarget.value == contactType
  102. {
  103. included.append(.Trend)
  104. }
  105. if Storage.shared.contactDelta.value == .include,
  106. Storage.shared.contactDeltaTarget.value == contactType
  107. {
  108. included.append(.Delta)
  109. }
  110. if Storage.shared.contactIOB.value == .include,
  111. Storage.shared.contactIOBTarget.value == contactType
  112. {
  113. included.append(.IOB)
  114. }
  115. return included
  116. }
  117. private func generateContactImage(bgValue: String, trend: String, delta: String, iob: String, stale: Bool, contactType: ContactType, includedFields: [ContactType], textColor: UIColor) -> UIImage? {
  118. let size = CGSize(width: 300, height: 300)
  119. UIGraphicsBeginImageContextWithOptions(size, false, 0)
  120. guard let context = UIGraphicsGetCurrentContext() else { return nil }
  121. savedBackgroundUIColor.setFill()
  122. context.fill(CGRect(origin: .zero, size: size))
  123. let paragraphStyle = NSMutableParagraphStyle()
  124. paragraphStyle.alignment = .center
  125. let yOffset: CGFloat = 48
  126. // Get the primary value for this contact type
  127. let primaryValue: String
  128. switch contactType {
  129. case .BG: primaryValue = bgValue
  130. case .Trend: primaryValue = trend
  131. case .Delta: primaryValue = delta
  132. case .IOB: primaryValue = iob
  133. }
  134. // Build extra values from included fields
  135. var extraValues: [String] = []
  136. for field in includedFields {
  137. switch field {
  138. case .Trend: extraValues.append(trend)
  139. case .Delta: extraValues.append(delta)
  140. case .IOB: extraValues.append(iob)
  141. case .BG: break
  142. }
  143. }
  144. let hasExtras = !extraValues.isEmpty
  145. // Determine font sizes based on number of extras
  146. let maxFontSize: CGFloat = extraValues.count >= 2 ? 140 : (hasExtras ? 160 : 200)
  147. let extraFontSize: CGFloat = extraValues.count >= 2 ? 60 : 90
  148. let fontSize = max(40, maxFontSize - CGFloat(primaryValue.count * 15))
  149. let isBGStale = stale && contactType == .BG
  150. var primaryAttributes: [NSAttributedString.Key: Any] = [
  151. .font: UIFont.boldSystemFont(ofSize: fontSize),
  152. .foregroundColor: isBGStale ? UIColor.gray : textColor,
  153. .paragraphStyle: paragraphStyle,
  154. ]
  155. if isBGStale {
  156. UIColor.black.setFill()
  157. context.fill(CGRect(origin: .zero, size: size))
  158. primaryAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
  159. }
  160. let primaryRect: CGRect = hasExtras
  161. ? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
  162. : CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
  163. primaryValue.draw(in: primaryRect, withAttributes: primaryAttributes)
  164. if hasExtras {
  165. let extraString = extraValues.joined(separator: " ")
  166. let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
  167. let extraAttributes: [NSAttributedString.Key: Any] = [
  168. .font: UIFont.systemFont(ofSize: extraFontSize),
  169. .foregroundColor: isBGStale ? UIColor.gray : textColor,
  170. .paragraphStyle: paragraphStyle,
  171. ]
  172. extraString.draw(in: extraRect, withAttributes: extraAttributes)
  173. }
  174. let image = UIGraphicsGetImageFromCurrentImageContext()
  175. UIGraphicsEndImageContext()
  176. return image
  177. }
  178. }