Skip to content

Commit afa3478

Browse files
authored
Merge pull request #342 from loopandlearn/bg-contact
Contact image with bg information
2 parents 97113d1 + 0b8ebd7 commit afa3478

File tree

10 files changed

+353
-3
lines changed

10 files changed

+353
-3
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE42ACF2383009A6922 /* Treatments.swift */; };
4949
DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */; };
5050
DD493AE92ACF2445009A6922 /* BGData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE82ACF2445009A6922 /* BGData.swift */; };
51+
DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */; };
52+
DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */; };
53+
DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; };
5154
DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */; };
5255
DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; };
5356
DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; };
@@ -286,6 +289,9 @@
286289
DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
287290
DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
288291
DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
292+
DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = "<group>"; };
293+
DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsView.swift; sourceTree = "<group>"; };
294+
DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = "<group>"; };
289295
DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinCartridgeChange.swift; sourceTree = "<group>"; };
290296
DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = "<group>"; };
291297
DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsViewModel.swift; sourceTree = "<group>"; };
@@ -602,6 +608,24 @@
602608
path = Treatments;
603609
sourceTree = "<group>";
604610
};
611+
DD50C74D2D0828250057AE6F /* Contact */ = {
612+
isa = PBXGroup;
613+
children = (
614+
DD50C7512D0828B40057AE6F /* Settings */,
615+
DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
616+
);
617+
path = Contact;
618+
sourceTree = "<group>";
619+
};
620+
DD50C7512D0828B40057AE6F /* Settings */ = {
621+
isa = PBXGroup;
622+
children = (
623+
DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */,
624+
DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */,
625+
);
626+
path = Settings;
627+
sourceTree = "<group>";
628+
};
605629
DD5334252C61667700062F9D /* InfoDisplaySettings */ = {
606630
isa = PBXGroup;
607631
children = (
@@ -832,6 +856,7 @@
832856
FC8DEEE32485D1680075863F /* LoopFollow */ = {
833857
isa = PBXGroup;
834858
children = (
859+
DD50C74D2D0828250057AE6F /* Contact */,
835860
DD5334252C61667700062F9D /* InfoDisplaySettings */,
836861
DD0C0C6E2C4AFFB800DBADDF /* Remote */,
837862
DD0C0C692C4852A100DBADDF /* Metric */,
@@ -1257,6 +1282,7 @@
12571282
DD13BC752C3FD6210062313B /* InfoType.swift in Sources */,
12581283
DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */,
12591284
DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
1285+
DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */,
12601286
FC97881C2485969B00A7906C /* MainViewController.swift in Sources */,
12611287
DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */,
12621288
DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */,
@@ -1266,6 +1292,7 @@
12661292
FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
12671293
DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
12681294
DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
1295+
DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
12691296
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
12701297
DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
12711298
DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
@@ -1295,6 +1322,7 @@
12951322
DD0C0C642C45A59400DBADDF /* HKUnit+Extensions.swift in Sources */,
12961323
DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */,
12971324
DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */,
1325+
DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
12981326
DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
12991327
DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
13001328
FCA2DDE62501095000254A8C /* Timers.swift in Sources */,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// ContactImageUpdater.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2024-12-10.
6+
// Copyright © 2024 Jon Fawcett. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import Contacts
11+
import UIKit
12+
13+
class ContactImageUpdater {
14+
private let contactStore = CNContactStore()
15+
private let queue = DispatchQueue(label: "ContactImageUpdaterQueue")
16+
17+
func updateContactImage(bgValue: String, extra: String, stale: Bool) {
18+
queue.async {
19+
guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
20+
print("Access to contacts is not authorized.")
21+
return
22+
}
23+
24+
guard let imageData = self.generateContactImage(bgValue: bgValue, extra: extra, stale: stale)?.pngData() else {
25+
print("Failed to generate contact image.")
26+
return
27+
}
28+
29+
let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow"
30+
let contactName = "\(bundleDisplayName) - BG"
31+
let predicate = CNContact.predicateForContacts(matchingName: contactName)
32+
let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
33+
34+
do {
35+
let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
36+
37+
if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
38+
mutableContact.imageData = imageData
39+
let saveRequest = CNSaveRequest()
40+
saveRequest.update(mutableContact)
41+
try self.contactStore.execute(saveRequest)
42+
print("Contact image updated successfully.")
43+
} else {
44+
let newContact = CNMutableContact()
45+
newContact.givenName = contactName
46+
newContact.imageData = imageData
47+
let saveRequest = CNSaveRequest()
48+
saveRequest.add(newContact, toContainerWithIdentifier: nil)
49+
try self.contactStore.execute(saveRequest)
50+
print("New contact created with updated image.")
51+
}
52+
} catch {
53+
print("Failed to update or create contact: \(error)")
54+
}
55+
}
56+
}
57+
58+
private func generateContactImage(bgValue: String, extra: String, stale: Bool) -> UIImage? {
59+
let size = CGSize(width: 300, height: 300)
60+
UIGraphicsBeginImageContextWithOptions(size, false, 0)
61+
guard let context = UIGraphicsGetCurrentContext() else { return nil }
62+
63+
UIColor.black.setFill()
64+
context.fill(CGRect(origin: .zero, size: size))
65+
66+
let paragraphStyle = NSMutableParagraphStyle()
67+
paragraphStyle.alignment = .center
68+
69+
let maxFontSize: CGFloat = extra.isEmpty ? 200 : 160
70+
let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
71+
72+
var bgAttributes: [NSAttributedString.Key: Any] = [
73+
.font: UIFont.boldSystemFont(ofSize: fontSize),
74+
.foregroundColor: stale ? UIColor.gray : UIColor.white,
75+
.paragraphStyle: paragraphStyle
76+
]
77+
78+
if stale {
79+
bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
80+
}
81+
82+
let extraAttributes: [NSAttributedString.Key: Any] = [
83+
.font: UIFont.systemFont(ofSize: 90),
84+
.foregroundColor: UIColor.white,
85+
.paragraphStyle: paragraphStyle
86+
]
87+
88+
let bgRect = extra.isEmpty
89+
? CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
90+
: CGRect(x: 0, y: 26, width: size.width, height: size.height / 2)
91+
92+
bgValue.draw(in: bgRect, withAttributes: bgAttributes)
93+
94+
if !extra.isEmpty {
95+
let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
96+
extra.draw(in: extraRect, withAttributes: extraAttributes)
97+
}
98+
99+
let image = UIGraphicsGetImageFromCurrentImageContext()
100+
UIGraphicsEndImageContext()
101+
return image
102+
}
103+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// ContactSettingsView.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2024-12-10.
6+
// Copyright © 2024 Jon Fawcett. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
import Contacts
11+
12+
struct ContactSettingsView: View {
13+
@ObservedObject var viewModel: ContactSettingsViewModel
14+
@Environment(\.presentationMode) var presentationMode
15+
16+
@State private var showAlert: Bool = false
17+
@State private var alertTitle: String = ""
18+
@State private var alertMessage: String = ""
19+
20+
var body: some View {
21+
NavigationView {
22+
Form {
23+
Section(header: Text("Contact Integration")) {
24+
Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.")
25+
.font(.footnote)
26+
.foregroundColor(.secondary)
27+
.padding(.vertical, 4)
28+
29+
Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled)
30+
.toggleStyle(SwitchToggleStyle())
31+
.onChange(of: viewModel.contactEnabled) { isEnabled in
32+
if isEnabled {
33+
requestContactAccess()
34+
}
35+
}
36+
}
37+
38+
if viewModel.contactEnabled {
39+
Section(header: Text("Additional Information")) {
40+
Toggle("Show Trend", isOn: $viewModel.contactTrend)
41+
.toggleStyle(SwitchToggleStyle())
42+
.onChange(of: viewModel.contactTrend) { isTrendEnabled in
43+
if isTrendEnabled {
44+
viewModel.contactDelta = false
45+
}
46+
}
47+
48+
Toggle("Show Delta", isOn: $viewModel.contactDelta)
49+
.toggleStyle(SwitchToggleStyle())
50+
.onChange(of: viewModel.contactDelta) { isDeltaEnabled in
51+
if isDeltaEnabled {
52+
viewModel.contactTrend = false
53+
}
54+
}
55+
}
56+
}
57+
}
58+
.navigationBarTitle("Contact Settings", displayMode: .inline)
59+
.toolbar {
60+
ToolbarItem(placement: .navigationBarTrailing) {
61+
Button("Done") {
62+
presentationMode.wrappedValue.dismiss()
63+
}
64+
}
65+
}
66+
.alert(isPresented: $showAlert) {
67+
Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
68+
}
69+
}
70+
}
71+
72+
private func requestContactAccess() {
73+
let contactStore = CNContactStore()
74+
let status = CNContactStore.authorizationStatus(for: .contacts)
75+
76+
if status == .authorized {
77+
// Already authorized, do nothing
78+
} else if status == .notDetermined {
79+
contactStore.requestAccess(for: .contacts) { granted, error in
80+
DispatchQueue.main.async {
81+
if !granted {
82+
viewModel.contactEnabled = false
83+
showAlert(title: "Access Denied", message: "Please allow access to Contacts in Settings to enable this feature.")
84+
}
85+
}
86+
}
87+
} else if status == .denied {
88+
viewModel.contactEnabled = false
89+
showAlert(title: "Access Denied", message: "Access to Contacts is denied. Please go to Settings and enable Contacts access.")
90+
} else if status == .restricted {
91+
viewModel.contactEnabled = false
92+
showAlert(title: "Access Restricted", message: "Access to Contacts is restricted.")
93+
} else {
94+
viewModel.contactEnabled = false
95+
showAlert(title: "Error", message: "An unknown error occurred while checking Contacts access.")
96+
}
97+
}
98+
99+
private func showAlert(title: String, message: String) {
100+
alertTitle = title
101+
alertMessage = message
102+
showAlert = true
103+
}
104+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// ContactSettingsViewModel.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2024-12-10.
6+
// Copyright © 2024 Jon Fawcett. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import Combine
11+
12+
extension Bundle {
13+
var displayName: String {
14+
return object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow"
15+
}
16+
}
17+
18+
class ContactSettingsViewModel: ObservableObject {
19+
var contactName: String {
20+
"\(Bundle.main.displayName) - BG"
21+
}
22+
23+
@Published var contactEnabled: Bool {
24+
didSet {
25+
storage.contactEnabled.value = contactEnabled
26+
triggerRefresh()
27+
}
28+
}
29+
30+
@Published var contactTrend: Bool {
31+
didSet {
32+
if contactTrend {
33+
contactDelta = false
34+
}
35+
storage.contactTrend.value = contactTrend
36+
triggerRefresh()
37+
}
38+
}
39+
40+
@Published var contactDelta: Bool {
41+
didSet {
42+
if contactDelta {
43+
contactTrend = false
44+
}
45+
storage.contactDelta.value = contactDelta
46+
triggerRefresh()
47+
}
48+
}
49+
50+
private let storage = ObservableUserDefaults.shared
51+
private var cancellables = Set<AnyCancellable>()
52+
53+
init() {
54+
self.contactEnabled = storage.contactEnabled.value
55+
self.contactTrend = storage.contactTrend.value
56+
self.contactDelta = storage.contactDelta.value
57+
58+
storage.contactEnabled.$value
59+
.assign(to: &$contactEnabled)
60+
61+
storage.contactTrend.$value
62+
.assign(to: &$contactTrend)
63+
64+
storage.contactDelta.$value
65+
.assign(to: &$contactDelta)
66+
}
67+
68+
private func triggerRefresh() {
69+
NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
70+
}
71+
}

LoopFollow/Controllers/Graphs.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,7 @@ extension MainViewController {
17951795
}
17961796

17971797
func wrapText(_ text: String, maxLineLength: Int) -> String {
1798+
return text
17981799
var lines: [String] = []
17991800
var currentLine = ""
18001801

LoopFollow/Controllers/Nightscout/BGData.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,19 @@ extension MainViewController {
286286
snoozer.BGLabel.text = snoozerBG
287287
snoozer.DirectionLabel.text = snoozerDirection
288288
snoozer.DeltaLabel.text = snoozerDelta
289+
290+
// Update contact
291+
if ObservableUserDefaults.shared.contactEnabled.value {
292+
var extra: String = ""
293+
294+
if ObservableUserDefaults.shared.contactTrend.value {
295+
extra = snoozerDirection
296+
} else if ObservableUserDefaults.shared.contactDelta.value {
297+
extra = snoozerDelta
298+
}
299+
300+
self.contactImageUpdater.updateContactImage(bgValue: bgTextStr, extra: extra, stale: deltaTime >= 12)
301+
}
289302
}
290303
}
291304
}

LoopFollow/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
<array>
9696
<string>UIInterfaceOrientationPortrait</string>
9797
</array>
98+
<key>NSContactsUsageDescription</key>
99+
<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
98100
<key>UISupportedInterfaceOrientations~ipad</key>
99101
<array>
100102
<string>UIInterfaceOrientationPortrait</string>

0 commit comments

Comments
 (0)