Преглед изворни кода

Showing bg on contact card

Jonas Björkert пре 1 година
родитељ
комит
4c22073db3

+ 28 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -48,6 +48,9 @@
 		DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE42ACF2383009A6922 /* Treatments.swift */; };
 		DD493AE72ACF23CF009A6922 /* DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */; };
 		DD493AE92ACF2445009A6922 /* BGData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD493AE82ACF2445009A6922 /* BGData.swift */; };
+		DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */; };
+		DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */; };
+		DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */; };
 		DD5334212C60EBEE00062F9D /* InsulinCartridgeChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */; };
 		DD5334232C60ED3600062F9D /* IAge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334222C60ED3600062F9D /* IAge.swift */; };
 		DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */; };
@@ -286,6 +289,9 @@
 		DD493AE42ACF2383009A6922 /* Treatments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Treatments.swift; sourceTree = "<group>"; };
 		DD493AE62ACF23CF009A6922 /* DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatus.swift; sourceTree = "<group>"; };
 		DD493AE82ACF2445009A6922 /* BGData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGData.swift; sourceTree = "<group>"; };
+		DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsViewModel.swift; sourceTree = "<group>"; };
+		DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSettingsView.swift; sourceTree = "<group>"; };
+		DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageUpdater.swift; sourceTree = "<group>"; };
 		DD5334202C60EBEE00062F9D /* InsulinCartridgeChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinCartridgeChange.swift; sourceTree = "<group>"; };
 		DD5334222C60ED3600062F9D /* IAge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAge.swift; sourceTree = "<group>"; };
 		DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplaySettingsViewModel.swift; sourceTree = "<group>"; };
@@ -602,6 +608,23 @@
 			path = Treatments;
 			sourceTree = "<group>";
 		};
+		DD50C74D2D0828250057AE6F /* Contact */ = {
+			isa = PBXGroup;
+			children = (
+				DD50C7512D0828B40057AE6F /* Settings */,
+			);
+			path = Contact;
+			sourceTree = "<group>";
+		};
+		DD50C7512D0828B40057AE6F /* Settings */ = {
+			isa = PBXGroup;
+			children = (
+				DD50C74F2D0828800057AE6F /* ContactSettingsViewModel.swift */,
+				DD50C7522D0828D10057AE6F /* ContactSettingsView.swift */,
+			);
+			path = Settings;
+			sourceTree = "<group>";
+		};
 		DD5334252C61667700062F9D /* InfoDisplaySettings */ = {
 			isa = PBXGroup;
 			children = (
@@ -693,6 +716,7 @@
 				FCA2DDE52501095000254A8C /* Timers.swift */,
 				DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */,
 				DDF699932C555B310058A8D9 /* ViewControllerManager.swift */,
+				DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
 			);
 			path = Controllers;
 			sourceTree = "<group>";
@@ -832,6 +856,7 @@
 		FC8DEEE32485D1680075863F /* LoopFollow */ = {
 			isa = PBXGroup;
 			children = (
+				DD50C74D2D0828250057AE6F /* Contact */,
 				DD5334252C61667700062F9D /* InfoDisplaySettings */,
 				DD0C0C6E2C4AFFB800DBADDF /* Remote */,
 				DD0C0C692C4852A100DBADDF /* Metric */,
@@ -1257,6 +1282,7 @@
 				DD13BC752C3FD6210062313B /* InfoType.swift in Sources */,
 				DDCF979C24C14EFB002C9752 /* AdvancedSettingsViewController.swift in Sources */,
 				DDD10EFF2C510C3C00D76A8E /* ObservableUserDefaultsValue.swift in Sources */,
+				DD50C7502D0828800057AE6F /* ContactSettingsViewModel.swift in Sources */,
 				FC97881C2485969B00A7906C /* MainViewController.swift in Sources */,
 				DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */,
 				DD493AD52ACF2109009A6922 /* ResumePump.swift in Sources */,
@@ -1266,6 +1292,7 @@
 				FC9788182485969B00A7906C /* AppDelegate.swift in Sources */,
 				DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
 				DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */,
+				DD50C7532D0828D10057AE6F /* ContactSettingsView.swift in Sources */,
 				DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */,
 				DD0C0C6B2C48562000DBADDF /* InsulinMetric.swift in Sources */,
 				DD493AD92ACF2171009A6922 /* Carbs.swift in Sources */,
@@ -1295,6 +1322,7 @@
 				DD0C0C642C45A59400DBADDF /* HKUnit+Extensions.swift in Sources */,
 				DDCF979A24C14DB4002C9752 /* WatchSettingsViewController.swift in Sources */,
 				DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */,
+				DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
 				DDD10F012C510C6B00D76A8E /* ObservableUserDefaults.swift in Sources */,
 				DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */,
 				FCA2DDE62501095000254A8C /* Timers.swift in Sources */,

+ 87 - 0
LoopFollow/Contact/Settings/ContactSettingsView.swift

@@ -0,0 +1,87 @@
+//
+//  ContactSettingsView.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2024-12-10.
+//  Copyright © 2024 Jon Fawcett. All rights reserved.
+//
+
+import SwiftUI
+import Contacts
+
+struct ContactSettingsView: View {
+    @ObservedObject var viewModel: ContactSettingsViewModel
+    @Environment(\.presentationMode) var presentationMode
+
+    @State private var showAlert: Bool = false
+    @State private var alertTitle: String = ""
+    @State private var alertMessage: String = ""
+
+    var body: some View {
+        NavigationView {
+            Form {
+                Section(header: Text("Contact Integration")) {
+                    Text("Add the contact named LoopFollowBG to your watch face to show the current BG value in real time.")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                        .padding(.vertical, 4)
+
+                    Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled)
+                        .toggleStyle(SwitchToggleStyle())
+                        .onChange(of: viewModel.contactEnabled) { isEnabled in
+                            if isEnabled {
+                                requestContactAccess()
+                            }
+                        }
+                }
+            }
+            .navigationBarTitle("Contact Settings", displayMode: .inline)
+            .toolbar {
+                ToolbarItem(placement: .navigationBarTrailing) {
+                    Button("Done") {
+                        presentationMode.wrappedValue.dismiss()
+                    }
+                }
+            }
+            .alert(isPresented: $showAlert) {
+                Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
+            }
+        }
+    }
+
+    private func requestContactAccess() {
+        let contactStore = CNContactStore()
+
+        switch CNContactStore.authorizationStatus(for: .contacts) {
+        case .authorized:
+            break
+        case .notDetermined:
+            contactStore.requestAccess(for: .contacts) { granted, error in
+                DispatchQueue.main.async {
+                    if !granted {
+                        viewModel.contactEnabled = false
+                        showAlert(title: "Access Denied", message: "Please allow access to Contacts in Settings to enable this feature.")
+                    }
+                }
+            }
+        case .denied:
+            viewModel.contactEnabled = false
+            showAlert(title: "Access Denied", message: "Access to Contacts is denied. Please go to Settings and enable Contacts access.")
+        case .restricted:
+            viewModel.contactEnabled = false
+            showAlert(title: "Access Restricted", message: "Access to Contacts is restricted.")
+        case .limited:
+            viewModel.contactEnabled = false
+            showAlert(title: "Access Limited", message: "Access to Contacts is limited.")
+        @unknown default:
+            viewModel.contactEnabled = false
+            showAlert(title: "Error", message: "An unknown error occurred while checking Contacts access.")
+        }
+    }
+
+    private func showAlert(title: String, message: String) {
+        alertTitle = title
+        alertMessage = message
+        showAlert = true
+    }
+}

+ 42 - 0
LoopFollow/Contact/Settings/ContactSettingsViewModel.swift

@@ -0,0 +1,42 @@
+//
+//  ContactSettingsViewModel.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2024-12-10.
+//  Copyright © 2024 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import Combine
+
+class ContactSettingsViewModel: ObservableObject {
+    @Published var contactEnabled: Bool
+
+    private var storage = ObservableUserDefaults.shared
+    private var cancellables = Set<AnyCancellable>()
+    private var isUpdatingFromStorage = false // Prevent recursive updates
+
+    init() {
+        self.contactEnabled = storage.contactEnabled.value
+
+        // Observe changes in UserDefaults
+        storage.contactEnabled.$value
+            .sink { [weak self] newValue in
+                guard let self = self else { return }
+                self.isUpdatingFromStorage = true
+                self.contactEnabled = newValue
+                self.isUpdatingFromStorage = false
+            }
+            .store(in: &cancellables)
+
+        // Update UserDefaults when local property changes
+        $contactEnabled
+            .sink { [weak self] newValue in
+                guard let self = self else { return }
+                // Prevent updating UserDefaults during storage sync
+                guard !self.isUpdatingFromStorage else { return }
+                self.storage.contactEnabled.value = newValue
+            }
+            .store(in: &cancellables)
+    }
+}

+ 100 - 0
LoopFollow/Controllers/ContactImageUpdater.swift

@@ -0,0 +1,100 @@
+//
+//  ContactImageUpdater.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2024-12-10.
+//  Copyright © 2024 Jon Fawcett. All rights reserved.
+//
+
+import Foundation
+import Contacts
+import UIKit
+
+class ContactImageUpdater {
+    private let contactStore = CNContactStore()
+    private let queue = DispatchQueue(label: "ContactImageUpdaterQueue")
+
+    func updateContactImage(bgValue: String, extra: String, stale: Bool) {
+        queue.async {
+            guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
+                print("Access to contacts is not authorized.")
+                return
+            }
+
+            guard let imageData = self.generateContactImage(bgValue: bgValue, extra: extra, stale: stale)?.pngData() else {
+                print("Failed to generate contact image.")
+                return
+            }
+
+            let predicate = CNContact.predicateForContacts(matchingName: "LoopFollowBG")
+            let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
+
+            do {
+                let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
+
+                if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
+                    mutableContact.imageData = imageData
+                    let saveRequest = CNSaveRequest()
+                    saveRequest.update(mutableContact)
+                    try self.contactStore.execute(saveRequest)
+                    print("Contact image updated successfully.")
+                } else {
+                    let newContact = CNMutableContact()
+                    newContact.givenName = "LoopFollowBG"
+                    newContact.imageData = imageData
+                    let saveRequest = CNSaveRequest()
+                    saveRequest.add(newContact, toContainerWithIdentifier: nil)
+                    try self.contactStore.execute(saveRequest)
+                    print("New contact created with updated image.")
+                }
+            } catch {
+                print("Failed to update or create contact: \(error)")
+            }
+        }
+    }
+
+    private func generateContactImage(bgValue: String, extra: String, stale: Bool) -> UIImage? {
+        let size = CGSize(width: 300, height: 300)
+        UIGraphicsBeginImageContextWithOptions(size, false, 0)
+        guard let context = UIGraphicsGetCurrentContext() else { return nil }
+
+        UIColor.systemBackground.setFill()
+        context.fill(CGRect(origin: .zero, size: size))
+
+        let bgText = bgValue
+        let paragraphStyle = NSMutableParagraphStyle()
+        paragraphStyle.alignment = .center
+
+        // Attributes for bgValue
+        var bgAttributes: [NSAttributedString.Key: Any] = [
+            .font: UIFont.boldSystemFont(ofSize: extra.isEmpty ? 140 : 100), // Larger font if no extra text
+            .foregroundColor: UIColor.label,
+            .paragraphStyle: paragraphStyle
+        ]
+
+        if stale {
+            bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+        }
+
+        let bgRect = extra.isEmpty
+        ? CGRect(x: 0, y: 80, width: size.width, height: 140) // Center bgValue vertically if no extra
+        : CGRect(x: 0, y: 60, width: size.width, height: 100)
+
+        bgText.draw(in: bgRect, withAttributes: bgAttributes)
+
+        // Draw extra text if not empty
+        if !extra.isEmpty {
+            let trendAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.systemFont(ofSize: 50),
+                .foregroundColor: UIColor.secondaryLabel,
+                .paragraphStyle: paragraphStyle
+            ]
+            let extraRect = CGRect(x: 0, y: 170, width: size.width, height: 50)
+            extra.draw(in: extraRect, withAttributes: trendAttributes)
+        }
+
+        let image = UIGraphicsGetImageFromCurrentImageContext()
+        UIGraphicsEndImageContext()
+        return image
+    }
+}

+ 1 - 0
LoopFollow/Controllers/Graphs.swift

@@ -1795,6 +1795,7 @@ extension MainViewController {
     }
 
     func wrapText(_ text: String, maxLineLength: Int) -> String {
+        return text
         var lines: [String] = []
         var currentLine = ""
 

+ 5 - 0
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -286,6 +286,11 @@ extension MainViewController {
             snoozer.BGLabel.text = snoozerBG
             snoozer.DirectionLabel.text = snoozerDirection
             snoozer.DeltaLabel.text = snoozerDelta
+
+            // Update contact
+            if ObservableUserDefaults.shared.contactEnabled.value {
+                self.contactImageUpdater.updateContactImage(bgValue: bgTextStr, extra: snoozerDelta, stale: deltaTime >= 12)
+            }
         }
     }
 }

+ 2 - 0
LoopFollow/Info.plist

@@ -95,6 +95,8 @@
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>
 	</array>
+	<key>NSContactsUsageDescription</key>
+	<string>This app requires access to contacts to update a contact image with real-time blood glucose information.</string>
 	<key>UISupportedInterfaceOrientations~ipad</key>
 	<array>
 		<string>UIInterfaceOrientationPortrait</string>

+ 4 - 0
LoopFollow/Storage/ObservableUserDefaults.swift

@@ -16,5 +16,9 @@ class ObservableUserDefaults {
     var device = ObservableUserDefaultsValue<String>(key: "device", default: "")
     var nsWriteAuth = ObservableUserDefaultsValue<Bool>(key: "nsWriteAuth", default: false)
 
+    var contactEnabled = ObservableUserDefaultsValue<Bool>(key: "contactEnabled", default: false)
+    var contactTrend = ObservableUserDefaultsValue<Bool>(key: "contactTrend", default: false)
+    var contactDelta = ObservableUserDefaultsValue<Bool>(key: "contactDelta", default: false)
+
     private init() {}
 }

+ 3 - 1
LoopFollow/ViewControllers/MainViewController.swift

@@ -152,6 +152,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     
     var IsNotLooping = false
 
+    let contactImageUpdater = ContactImageUpdater()
+
     override func viewDidLoad() {
         super.viewDidLoad()
 
@@ -553,7 +555,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             self.processCalendarUpdates()
         }
     }
-    
+
     func processCalendarUpdates() {
         if UserDefaultsRepository.calendarIdentifier.value == "" { return }
         

+ 24 - 2
LoopFollow/ViewControllers/SettingsViewController.swift

@@ -258,7 +258,7 @@ class SettingsViewController: FormViewController {
 
         +++ Section("Integrations")
         <<< ButtonRow() {
-            $0.title = "Apple Watch and Carplay"
+            $0.title = "Calendar"
             $0.presentationMode = .show(
                 controllerProvider: .callback(builder: {
                     let controller = WatchSettingsViewController()
@@ -268,7 +268,15 @@ class SettingsViewController: FormViewController {
                                              ), onDismiss: nil)
 
         }
-
+        <<< ButtonRow("contact") {
+            $0.title = "Contact"
+            $0.presentationMode = .show(
+                controllerProvider: .callback(builder: {
+                    self.presentContactSettings()
+                    return UIViewController()
+                }
+                                             ), onDismiss: nil)
+        }
         +++ Section("Advanced Settings")
         <<< ButtonRow() {
             $0.title = "Advanced Settings"
@@ -416,4 +424,18 @@ class SettingsViewController: FormViewController {
 
         present(hostingController, animated: true, completion: nil)
     }
+
+
+    func presentContactSettings() {
+        let viewModel = ContactSettingsViewModel()
+        let contactSettingsView = ContactSettingsView(viewModel: viewModel)
+        let hostingController = UIHostingController(rootView: contactSettingsView)
+        hostingController.modalPresentationStyle = .formSheet
+
+        if UserDefaultsRepository.forceDarkMode.value {
+            hostingController.overrideUserInterfaceStyle = .dark
+        }
+
+        present(hostingController, animated: true, completion: nil)
+    }
 }