Просмотр исходного кода

Refactor logic for ContactTrick CRUD operations

polscm32 aka Marvout 1 год назад
Родитель
Сommit
d936a8183a

+ 4 - 12
FreeAPS.xcodeproj/project.pbxproj

@@ -342,6 +342,7 @@
 		BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDBAACF92C2D439700370AAE /* OverrideData.swift */; };
 		BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */; };
 		BDC2EA472C3045AD00E5BBD0 /* Override.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC2EA462C3045AD00E5BBD0 /* Override.swift */; };
+		BDC530FF2D0F6BE300088832 /* Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC530FE2D0F6BE300088832 /* Manager.swift */; };
 		BDCAF2382C639F35002DC907 /* SettingItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCAF2372C639F35002DC907 /* SettingItems.swift */; };
 		BDCD47AF2C1F3F1700F8BCD5 /* OverrideStored+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */; };
 		BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDDAF9EE2D00553E00B34E7A /* SelectionPopoverView.swift */; };
@@ -542,9 +543,6 @@
 		E592A3782CEEC038009A472C /* ContactTrickDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3732CEEC038009A472C /* ContactTrickDataFlow.swift */; };
 		E592A3792CEEC038009A472C /* ContactTrickRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3712CEEC038009A472C /* ContactTrickRootView.swift */; };
 		E592A37A2CEEC038009A472C /* ContactTrickProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3742CEEC038009A472C /* ContactTrickProvider.swift */; };
-		E592A37F2CEEC046009A472C /* ContactPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A37D2CEEC046009A472C /* ContactPicture.swift */; };
-		E592A3802CEEC046009A472C /* ContactTrickManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A37B2CEEC046009A472C /* ContactTrickManager.swift */; };
-		E592A3812CEEC046009A472C /* ContactTrickState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A37C2CEEC046009A472C /* ContactTrickState.swift */; };
 		E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */; };
 		F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */; };
 		F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */; };
@@ -1040,6 +1038,7 @@
 		BDBAACF92C2D439700370AAE /* OverrideData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideData.swift; sourceTree = "<group>"; };
 		BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorage.swift; sourceTree = "<group>"; };
 		BDC2EA462C3045AD00E5BBD0 /* Override.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Override.swift; sourceTree = "<group>"; };
+		BDC530FE2D0F6BE300088832 /* Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Manager.swift; sourceTree = "<group>"; };
 		BDCAF2372C639F35002DC907 /* SettingItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingItems.swift; sourceTree = "<group>"; };
 		BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OverrideStored+helper.swift"; sourceTree = "<group>"; };
 		BDDAF9EE2D00553E00B34E7A /* SelectionPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionPopoverView.swift; sourceTree = "<group>"; };
@@ -1242,9 +1241,6 @@
 		E592A3732CEEC038009A472C /* ContactTrickDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickDataFlow.swift; sourceTree = "<group>"; };
 		E592A3742CEEC038009A472C /* ContactTrickProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickProvider.swift; sourceTree = "<group>"; };
 		E592A3752CEEC038009A472C /* ContactTrickStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickStateModel.swift; sourceTree = "<group>"; };
-		E592A37B2CEEC046009A472C /* ContactTrickManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickManager.swift; sourceTree = "<group>"; };
-		E592A37C2CEEC046009A472C /* ContactTrickState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickState.swift; sourceTree = "<group>"; };
-		E592A37D2CEEC046009A472C /* ContactPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPicture.swift; sourceTree = "<group>"; };
 		E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsProvider.swift; sourceTree = "<group>"; };
 		F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = "<group>"; };
 		F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = "<group>"; };
@@ -3009,9 +3005,7 @@
 		E592A37E2CEEC046009A472C /* ContactTrick */ = {
 			isa = PBXGroup;
 			children = (
-				E592A37B2CEEC046009A472C /* ContactTrickManager.swift */,
-				E592A37C2CEEC046009A472C /* ContactTrickState.swift */,
-				E592A37D2CEEC046009A472C /* ContactPicture.swift */,
+				BDC530FE2D0F6BE300088832 /* Manager.swift */,
 			);
 			path = ContactTrick;
 			sourceTree = "<group>";
@@ -3437,9 +3431,6 @@
 				38B4F3CA25E502E200E76A18 /* SwiftNotificationCenter.swift in Sources */,
 				38AEE75225F022080013F05B /* SettingsManager.swift in Sources */,
 				3894873A2614928B004DF424 /* DispatchTimer.swift in Sources */,
-				E592A37F2CEEC046009A472C /* ContactPicture.swift in Sources */,
-				E592A3802CEEC046009A472C /* ContactTrickManager.swift in Sources */,
-				E592A3812CEEC046009A472C /* ContactTrickState.swift in Sources */,
 				3895E4C625B9E00D00214B37 /* Preferences.swift in Sources */,
 				CE94598429E9E3E60047C9C6 /* WatchConfigStateModel.swift in Sources */,
 				DD6B7CB92C7BAC6900B75029 /* NightscoutImportResultView.swift in Sources */,
@@ -3854,6 +3845,7 @@
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
+				BDC530FF2D0F6BE300088832 /* Manager.swift in Sources */,
 				DDE179552C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift in Sources */,
 				DDE179562C910127003CDDB7 /* BolusStored+CoreDataClass.swift in Sources */,
 				DDE179572C910127003CDDB7 /* BolusStored+CoreDataProperties.swift in Sources */,

+ 85 - 21
FreeAPS/Sources/APS/Storage/ContactTrickStorage.swift

@@ -6,61 +6,78 @@ import Swinject
 protocol ContactTrickStorage {
     func fetchContactTrickEntries() async -> [ContactTrickEntry]
     func storeContactTrickEntry(_ entry: ContactTrickEntry) async
+    func updateContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async
     func deleteContactTrickEntry(_ objectID: NSManagedObjectID) async
 }
 
 final class BaseContactTrickStorage: ContactTrickStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
-    private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     private let backgroundContext = CoreDataStack.shared.newTaskContext()
 
     init(resolver: Resolver) {
         injectServices(resolver)
     }
 
+    /// Fetches all stored Contact Trick entries.
+    ///
+    /// The method retrieves `ContactTrickEntryStored` objects from Core Data, maps them to
+    /// `ContactTrickEntry` objects, and returns the results.
+    ///
+    /// - Returns: An array of `ContactTrickEntry` objects.
     func fetchContactTrickEntries() async -> [ContactTrickEntry] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: ContactTrickEntryStored.self,
             onContext: backgroundContext,
             predicate: NSPredicate.all,
-            key: "contactId",
+            key: "isDarkMode",
             ascending: false
         )
 
-        guard let fetchedContactTrickEntries = results as? [ContactTrickEntryStored] else { return [] }
+        return await backgroundContext.perform {
+            guard let fetchedContactTrickEntries = results as? [ContactTrickEntryStored] else { return [] }
 
-        return fetchedContactTrickEntries.map { entry in
-            ContactTrickEntry(
-                layout: ContactTrickLayout.init(rawValue: entry.layout ?? "Single") ?? .single,
-                ring: ContactTrickLargeRing.init(rawValue: entry.ring ?? "DontShowRing") ?? .none,
-                primary: ContactTrickValue.init(rawValue: entry.primary ?? "GlucoseContactValue") ?? .glucose,
-                top: ContactTrickValue.init(rawValue: entry.top ?? "NoneContactValue") ?? .none,
-                bottom: ContactTrickValue.init(rawValue: entry.top ?? "NoneContactValue") ?? .none,
-                contactId: entry.contactId?.string,
-                darkMode: entry.isDarkMode,
-                ringWidth: ContactTrickEntry.RingWidth.init(rawValue: Int(entry.ringWidth)) ?? .regular,
-                ringGap: ContactTrickEntry.RingGap.init(rawValue: Int(entry.ringWidth)) ?? .small,
-                fontSize: ContactTrickEntry.FontSize.init(rawValue: Int(entry.fontSize)) ?? .regular,
-                secondaryFontSize: ContactTrickEntry.FontSize.init(rawValue: Int(entry.fontSize)) ?? .small,
-                fontWeight: Font.Weight.fromString(entry.fontWeight ?? "regular"),
-                fontWidth: Font.Width.fromString(entry.fontWidth ?? "standard")
-            )
+            return fetchedContactTrickEntries.compactMap { entry in
+                ContactTrickEntry(
+                    name: entry.name ?? "No name provided",
+                    layout: ContactTrickLayout(rawValue: entry.layout ?? "Single") ?? .single,
+                    ring: ContactTrickLargeRing(rawValue: entry.ring ?? "DontShowRing") ?? .none,
+                    primary: ContactTrickValue(rawValue: entry.primary ?? "GlucoseContactValue") ?? .glucose,
+                    top: ContactTrickValue(rawValue: entry.top ?? "NoneContactValue") ?? .none,
+                    bottom: ContactTrickValue(rawValue: entry.bottom ?? "NoneContactValue") ?? .none,
+                    contactId: entry.contactId?.string,
+                    darkMode: entry.isDarkMode,
+                    ringWidth: ContactTrickEntry.RingWidth(rawValue: Int(entry.ringWidth)) ?? .regular,
+                    ringGap: ContactTrickEntry.RingGap(rawValue: Int(entry.ringGap)) ?? .small,
+                    fontSize: ContactTrickEntry.FontSize(rawValue: Int(entry.fontSize)) ?? .regular,
+                    secondaryFontSize: ContactTrickEntry.FontSize(rawValue: Int(entry.fontSizeSecondary)) ?? .small,
+                    fontWeight: Font.Weight.fromString(entry.fontWeight ?? "regular"),
+                    fontWidth: Font.Width.fromString(entry.fontWidth ?? "standard"),
+                    managedObjectID: entry.objectID
+                )
+            }
         }
     }
 
+    /// Stores a new Contact Trick entry.
+    ///
+    /// This method creates a new `ContactTrickEntryStored` object in the background context,
+    /// populates its properties with the values from the provided `ContactTrickEntry`, and
+    /// saves the context if changes exist.
+    ///
+    /// - Parameter contactTrickEntry: The `ContactTrickEntry` object to be stored.
     func storeContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async {
         await backgroundContext.perform {
             let newContactTrickEntry = ContactTrickEntryStored(context: self.backgroundContext)
 
             newContactTrickEntry.id = UUID()
+            newContactTrickEntry.name = contactTrickEntry.name
             newContactTrickEntry.contactId = contactTrickEntry.contactId
             newContactTrickEntry.layout = contactTrickEntry.layout.rawValue
             newContactTrickEntry.ring = contactTrickEntry.ring.rawValue
             newContactTrickEntry.primary = contactTrickEntry.primary.rawValue
             newContactTrickEntry.top = contactTrickEntry.top.rawValue
             newContactTrickEntry.bottom = contactTrickEntry.bottom.rawValue
-            newContactTrickEntry.contactId = contactTrickEntry.ring.rawValue
             newContactTrickEntry.isDarkMode = contactTrickEntry.darkMode
             newContactTrickEntry.ringWidth = Int16(contactTrickEntry.ringWidth.rawValue)
             newContactTrickEntry.ringGap = Int16(contactTrickEntry.ringGap.rawValue)
@@ -74,12 +91,59 @@ final class BaseContactTrickStorage: ContactTrickStorage, Injectable {
                 try self.backgroundContext.save()
             } catch let error as NSError {
                 debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contract Trick Entry to Core Data with error: \(error.userInfo)"
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to save Contact Trick Entry to Core Data with error: \(error.userInfo)"
+                )
+            }
+        }
+    }
+
+    /// Updates an existing Contact Trick entry in Core Data.
+    ///
+    /// This method finds the existing `ContactTrickEntryStored` object by its `contactId` and updates
+    /// its properties with the values from the provided `ContactTrickEntry`. If no matching entry exists,
+    /// it does nothing.
+    ///
+    /// - Parameter contactTrickEntry: The `ContactTrickEntry` object with updated values.
+    func updateContactTrickEntry(_ contactTrickEntry: ContactTrickEntry) async {
+        await backgroundContext.perform {
+            let fetchRequest: NSFetchRequest<ContactTrickEntryStored> = ContactTrickEntryStored.fetchRequest()
+            fetchRequest.predicate = NSPredicate(format: "contactId == %@", contactTrickEntry.contactId ?? "")
+
+            do {
+                if let existingEntry = try self.backgroundContext.fetch(fetchRequest).first {
+                    // Update the properties of the existing entry
+                    existingEntry.name = contactTrickEntry.name
+                    existingEntry.layout = contactTrickEntry.layout.rawValue
+                    existingEntry.ring = contactTrickEntry.ring.rawValue
+                    existingEntry.primary = contactTrickEntry.primary.rawValue
+                    existingEntry.top = contactTrickEntry.top.rawValue
+                    existingEntry.bottom = contactTrickEntry.bottom.rawValue
+                    existingEntry.isDarkMode = contactTrickEntry.darkMode
+                    existingEntry.ringWidth = Int16(contactTrickEntry.ringWidth.rawValue)
+                    existingEntry.ringGap = Int16(contactTrickEntry.ringGap.rawValue)
+                    existingEntry.fontSize = Int16(contactTrickEntry.fontSize.rawValue)
+                    existingEntry.fontSizeSecondary = Int16(contactTrickEntry.secondaryFontSize.rawValue)
+                    existingEntry.fontWeight = contactTrickEntry.fontWeight.asString
+                    existingEntry.fontWidth = contactTrickEntry.fontWidth.asString
+
+                    guard self.backgroundContext.hasChanges else { return }
+                    try self.backgroundContext.save()
+                } else {
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) \(#file) \(#function) No matching Contact Trick Entry found to update."
+                    )
+                }
+            } catch let error as NSError {
+                debugPrint(
+                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update Contact Trick Entry with error: \(error.userInfo)"
                 )
             }
         }
     }
 
+    /// Deletes a Contact Trick entry from Core Data.
+    ///
+    /// - Parameter objectID: The `NSManagedObjectID` of the object to delete.
     func deleteContactTrickEntry(_ objectID: NSManagedObjectID) async {
         await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
     }

+ 0 - 1
FreeAPS/Sources/Assemblies/ServiceAssembly.swift

@@ -20,7 +20,6 @@ final class ServiceAssembly: Assembly {
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(GarminManager.self) { r in BaseGarminManager(resolver: r) }
-        container.register(ContactTrickManager.self) { r in BaseContactTrickManager(resolver: r) }
         container.register(AlertPermissionsChecker.self) { r in AlertPermissionsChecker(resolver: r) }
         if #available(iOS 16.2, *) {
             container.register(LiveActivityBridge.self) { r in

+ 5 - 2
FreeAPS/Sources/Models/ContactTrickEntry.swift

@@ -1,7 +1,9 @@
+import CoreData
 import SwiftUI
 
 struct ContactTrickEntry: Hashable, Sendable {
     var id = UUID()
+    var name: String = ""
     var layout: ContactTrickLayout = .single
     var ring: ContactTrickLargeRing = .none
     var primary: ContactTrickValue = .glucose
@@ -15,6 +17,7 @@ struct ContactTrickEntry: Hashable, Sendable {
     var secondaryFontSize: FontSize = .small
     var fontWeight: Font.Weight = .medium
     var fontWidth: Font.Width = .standard
+    var managedObjectID: NSManagedObjectID?
 
     // Convert `fontWeight` to a String for Core Data storage
     var fontWeightString: String {
@@ -52,7 +55,7 @@ struct ContactTrickEntry: Hashable, Sendable {
         }
     }
 
-    enum RingWidth: Int, Codable, Sendable {
+    enum RingWidth: Int, Codable, Sendable, CaseIterable {
         case tiny = 3
         case small = 5
         case regular = 7
@@ -70,7 +73,7 @@ struct ContactTrickEntry: Hashable, Sendable {
         }
     }
 
-    enum RingGap: Int, Codable, Sendable {
+    enum RingGap: Int, Codable, Sendable, CaseIterable {
         case tiny = 1
         case small = 2
         case regular = 3

+ 0 - 1
FreeAPS/Sources/Modules/Base/BaseProvider.swift

@@ -11,7 +11,6 @@ class BaseProvider: Provider, Injectable {
     @Injected() var deviceManager: DeviceDataManager!
     @Injected() var storage: FileStorage!
     @Injected() var bluetoothProvider: BluetoothStateManager!
-    @Injected() var contactTrickManager: ContactTrickManager!
 
     required init(resolver: Resolver) {
         injectServices(resolver)

+ 0 - 19
FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift

@@ -3,25 +3,6 @@ import Foundation
 
 enum ContactTrick {
     enum Config {}
-
-    class Item: Identifiable, Hashable, Equatable {
-        let id = UUID()
-        var index: Int = 0
-        var entry: ContactTrickEntry
-
-        init(index: Int, entry: ContactTrickEntry) {
-            self.index = index
-            self.entry = entry
-        }
-
-        static func == (lhs: Item, rhs: Item) -> Bool {
-            lhs.index == rhs.index
-        }
-
-        func hash(into hasher: inout Hasher) {
-            hasher.combine(index)
-        }
-    }
 }
 
 protocol ContactTrickProvider: Provider {}

+ 93 - 50
FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift

@@ -1,79 +1,122 @@
 import ConnectIQ
+import CoreData
 import SwiftUI
 
 extension ContactTrick {
     @Observable final class StateModel: BaseStateModel<Provider> {
-        private(set) var syncInProgress = false
-        private(set) var items: [Item] = []
-        private(set) var changed: Bool = false
-
-        @ObservationIgnored @Injected() var contactTrickManager: ContactTrickManager!
         @ObservationIgnored @Injected() var contactTrickStorage: ContactTrickStorage!
+        var contactTrickEntries = [ContactTrickEntry]()
+        private let contactManager = ContactManager()
 
         var units: GlucoseUnits = .mmolL
 
+        /// Subscribes to updates and initializes data fetching.
         override func subscribe() {
             units = settingsManager.settings.units
-            items = contactTrickManager.currentContacts.enumerated().map { index, contact in
-                Item(
-                    index: index,
-                    entry: contact
-                )
+            Task {
+                /// Initial fetch to fill the ContactTrickEntry array
+                await fetchContactTrickEntriesAndUpdateUI()
             }
-            changed = false
         }
 
-        func add() {
-            let newItem = Item(
-                index: items.count,
-                entry: ContactTrickEntry()
-            )
-
-            items.append(newItem)
-            changed = true
+        /// Fetches all ContactTrickEntries from Core Data.
+        func fetchContactTrickEntriesAndUpdateUI() async {
+            let entries = await contactTrickStorage.fetchContactTrickEntries()
+            await MainActor.run {
+                self.contactTrickEntries = entries
+            }
         }
 
-        func update(_ atIndex: Int, _ value: ContactTrickEntry) {
-            items[atIndex].entry = value
-            changed = true
+        /// Creates a new contact in Apple Contacts and saves it to Core Data.
+        /// - Parameters:
+        ///   - entry: The ContactTrickEntry to be saved.
+        ///   - name: The name of the contact.
+        func createAndSaveContactTrick(entry: ContactTrickEntry, name: String) async {
+            // 1. Check for contact access permissions.
+            let hasAccess = await contactManager.requestAccess()
+            guard hasAccess else {
+                print("No access to contacts.")
+                return
+            }
+
+            // 2. Create the contact and retrieve its `identifier`.
+            guard let contactId = await contactManager.createContact(name: name) else {
+                print("Failed to create contact.")
+                return
+            }
+
+            // 3. Update the entry with the `contactId`.
+            var updatedEntry = entry
+            updatedEntry.contactId = contactId
+
+            // 4. Save the contact to Core Data.
+            await addContactTrickEntry(updatedEntry)
         }
 
-        func remove(atOffsets: IndexSet) {
-            items.remove(atOffsets: atOffsets)
-            changed = true
+        /// Adds a ContactTrickEntry to Core Data.
+        /// - Parameter entry: The ContactTrickEntry to be saved.
+        func addContactTrickEntry(_ entry: ContactTrickEntry) async {
+            await contactTrickStorage.storeContactTrickEntry(entry)
+            await fetchContactTrickEntriesAndUpdateUI()
         }
 
-        @MainActor func save() async {
-            syncInProgress = true
-            let contacts = items.map { item -> ContactTrickEntry in
-                item.entry
+        /// Deletes a contact from Apple Contacts and Core Data.
+        /// - Parameter entry: The ContactTrickEntry representing the contact to be deleted.
+        func deleteContact(entry: ContactTrickEntry) async {
+            guard let contactId = entry.contactId else {
+                print("Contact does not have a valid ID.")
+                return
             }
 
-            let didUpdateStatus = await contactTrickManager.updateContacts(contacts: contacts)
+            // 1. Attempt to delete the contact from Apple Contacts.
+            let contactDeleted = await contactManager.deleteContact(withIdentifier: contactId)
+            if contactDeleted {
+                print("Contact successfully deleted from Apple Contacts: \(contactId)")
+            } else {
+                print("Failed to delete contact from Apple Contacts. Check if it exists.")
+            }
 
-            //            for contact in contacts {
-            //                await contactTrickStorage.storeContactTrickEntry(contact)
-            //            }
+            // 2. Delete the entry from Core Data.
+            if let objectID = entry.managedObjectID {
+                await deleteContactTrick(objectID: objectID)
+            }
+        }
+
+        /// Deletes a Core Data entry.
+        /// - Parameter objectID: The Managed Object ID of the entry to be deleted.
+        func deleteContactTrick(objectID: NSManagedObjectID) async {
+            await contactTrickStorage.deleteContactTrickEntry(objectID)
+            await fetchContactTrickEntriesAndUpdateUI()
+        }
 
-            syncInProgress = didUpdateStatus
-            changed = didUpdateStatus
+        /// Updates a contact in Apple Contacts and Core Data.
+        /// - Parameters:
+        ///   - entry: The ContactTrickEntry to be updated.
+        ///   - newName: The new name to assign to the contact.
+        func updateContact(entry: ContactTrickEntry, newName: String) async {
+            guard let contactId = entry.contactId else {
+                print("Contact does not have a valid ID.")
+                return
+            }
 
-            if didUpdateStatus {
-                contacts.enumerated().forEach { index, item in
-                    self.items[index].entry = item
-                }
+            // 1. Update the contact in Apple Contacts.
+            let contactUpdated = await contactManager.updateContact(withIdentifier: contactId, newName: newName)
+            guard contactUpdated else {
+                print("Failed to update contact in Apple Contacts.")
+                return
             }
-//            provider.saveContacts(contacts)
-//                .receive(on: DispatchQueue.main)
-//                .sink { _ in
-//                    self.syncInProgress = false
-//                    self.changed = false
-//                } receiveValue: { contacts in
-//                    contacts.enumerated().forEach { index, item in
-//                        self.items[index].entry = item
-//                    }
-//                }
-//                .store(in: &lifetime)
+
+            // 2. Update the entry in Core Data.
+            var updatedEntry = entry
+            updatedEntry.name = newName // Update additional fields if needed.
+            await updateContactTrick(updatedEntry)
+        }
+
+        /// Updates a Core Data entry.
+        /// - Parameter entry: The updated ContactTrickEntry.
+        func updateContactTrick(_ entry: ContactTrickEntry) async {
+            await contactTrickStorage.updateContactTrickEntry(entry)
+            await fetchContactTrickEntriesAndUpdateUI()
         }
     }
 }

+ 167 - 354
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift

@@ -6,406 +6,219 @@ import Swinject
 extension ContactTrick {
     struct RootView: BaseView {
         let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        @State private var contactStore = CNContactStore()
-        @State private var authorization = CNContactStore.authorizationStatus(for: .contacts)
-        @State private var contactTrickEntries = [ContactTrickEntry]()
+        @State var state = StateModel()
+        @State private var isAddSheetPresented = false
 
         var body: some View {
-            Form {
-                switch authorization {
-                case .authorized:
-                    Section(header: Text("Contacts")) {
-                        list
-                        addButton
-                    }
-                    Section(
-                        header: state.changed ?
-                            Text("Don't forget to save your changes.")
-                            .frame(maxWidth: .infinity, alignment: .center)
-                            .foregroundStyle(.primary) : nil
-                    ) {
-                        HStack {
-                            if state.syncInProgress {
-                                ProgressView().padding(.trailing, 10)
-                            }
-                            Button { Task { await state.save() } }
-                            label: {
-                                Text(state.syncInProgress ? "Saving..." : "Save")
-                            }
-                            .disabled(state.syncInProgress || !state.changed)
-                            .frame(maxWidth: .infinity, alignment: .center)
+            NavigationView {
+                List {
+                    ForEach(state.contactTrickEntries, id: \.id) { entry in
+                        NavigationLink(destination: ContactTrickDetailView(entry: entry, state: state)) {
+                            Text("\(entry.name)")
                         }
                     }
+                    .onDelete(perform: onDelete)
+                }
+                .navigationTitle("Contact Tricks")
+                .onAppear(perform: configureView)
+                .navigationBarItems(
+                    trailing: Button(action: {
+                        isAddSheetPresented.toggle()
+                    }) {
+                        Image(systemName: "plus")
+                    }
+                )
+                .sheet(isPresented: $isAddSheetPresented) {
+                    AddContactTrickSheet(state: state)
+                }
+            }
+        }
 
-                case .notDetermined:
-                    Section {
-                        Text(
-                            "Trio needs access to your contacts for this feature to work"
-                        )
-                    }
-                    Section {
-                        Button(action: onRequestContactsAccess) {
-                            Text("Grant Trio access to contacts")
+        private func onDelete(offsets: IndexSet) {
+            Task {
+                for offset in offsets {
+                    let entry = state.contactTrickEntries[offset]
+                    await state.deleteContact(entry: entry)
+                }
+            }
+        }
+    }
+}
+
+struct AddContactTrickSheet: View {
+    @Environment(\.dismiss) var dismiss
+    var state: ContactTrick.StateModel
+
+    @State private var name: String = ""
+    @State private var isDarkMode: Bool = false
+    @State private var ringWidth: ContactTrickEntry.RingWidth = .regular
+    @State private var ringGap: ContactTrickEntry.RingGap = .small
+    @State private var layout: ContactTrickLayout = .single
+    @State private var primary: ContactTrickValue = .glucose
+    @State private var top: ContactTrickValue = .none
+    @State private var bottom: ContactTrickValue = .none
+
+    var body: some View {
+        NavigationView {
+            Form {
+                TextField("Name", text: $name)
+                Section(header: Text("Layout")) {
+                    Toggle("Dark Mode", isOn: $isDarkMode)
+                    Picker("Layout", selection: $layout) {
+                        ForEach(ContactTrickLayout.allCases, id: \.id) { layout in
+                            Text(layout.displayName).tag(layout)
                         }
                     }
+                    .pickerStyle(SegmentedPickerStyle())
+                }
 
-                case .denied:
-                    Section {
-                        Text(
-                            "Access to contacts denied"
-                        )
+                Section(header: Text("Primary Value")) {
+                    Picker("Primary", selection: $primary) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
                     }
+                }
 
-                case .restricted:
-                    Section {
-                        Text(
-                            "Access to contacts is restricted (parental control?)"
-                        )
+                Section(header: Text("Additional Values")) {
+                    Picker("Top Value", selection: $top) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
                     }
 
-                case .limited:
-                    Section {
-                        Text(
-                            "Access to contacts is limited. Trio needs full access to contacts for this feature to work"
-                        )
-                    }
-                @unknown default:
-                    Section {
-                        Text(
-                            "Access to contacts - unknown state"
-                        )
+                    Picker("Bottom Value", selection: $bottom) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
                     }
                 }
 
-                Section {}
-                footer: {
-                    Text(
-                        "A Contact Image can be used to get live updates from Trio to your Apple Watch Contact complication and/or your iPhone Contact widget."
-                    )
-                    .frame(maxWidth: .infinity, alignment: .center)
+                Section(header: Text("Ring Settings")) {
+                    Picker("Ring Width", selection: $ringWidth) {
+                        ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
+                            Text(width.displayName).tag(width)
+                        }
+                    }
+
+                    Picker("Ring Gap", selection: $ringGap) {
+                        ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
+                            Text(gap.displayName).tag(gap)
+                        }
+                    }
                 }
             }
-            .dynamicTypeSize(...DynamicTypeSize.xxLarge)
-            .onAppear(perform: configureView)
-            .navigationTitle("Contact Image")
-            .navigationBarTitleDisplayMode(.automatic)
+            .navigationBarTitle("Add Contact Trick", displayMode: .inline)
             .navigationBarItems(
-                trailing: EditButton()
+                leading: Button("Cancel") {
+                    dismiss()
+                },
+                trailing: Button("Save") {
+                    saveNewEntry()
+                }
             )
         }
+    }
 
-        private func contactSettings(for index: Int) -> some View {
-            EntryView(entry: Binding(
-                get: { state.items[index].entry },
-                set: { newValue in state.update(index, newValue) }
-            ), previewState: previewState)
+    private func saveNewEntry() {
+        let newEntry = ContactTrickEntry(
+            id: UUID(),
+            name: name,
+            layout: layout,
+            ring: .none,
+            primary: primary,
+            top: top,
+            bottom: bottom,
+            contactId: nil, // Wird später durch die API gesetzt
+            darkMode: isDarkMode,
+            ringWidth: ringWidth,
+            ringGap: ringGap,
+            fontSize: .regular,
+            secondaryFontSize: .small,
+            fontWeight: .medium,
+            fontWidth: .standard
+        )
+        Task {
+            await state.createAndSaveContactTrick(entry: newEntry, name: name)
+            dismiss()
         }
+    }
+}
 
-        var previewState: ContactTrickState {
-            let units = state.units
+struct ContactTrickDetailView: View {
+    @Environment(\.dismiss) var dismiss
+    @ObservedObject var state: ContactTrick.StateModel
 
-            return ContactTrickState(
-                glucose: units == .mmolL ? "6,8" : "127",
-                trend: "↗︎",
-                delta: units == .mmolL ? "+0,3" : "+7",
-                lastLoopDate: .now,
-                iob: 6.1,
-                iobText: "6,1",
-                cob: 27.0,
-                cobText: "27",
-                eventualBG: units == .mmolL ? "8,9" : "163",
-                maxIOB: 12.0,
-                maxCOB: 120.0
-            )
-        }
+    @State private var contactTrickEntry: ContactTrickEntry
 
-        private var list: some View {
-            List {
-                ForEach(state.items.indexed(), id: \.1.id) { index, item in
-                    NavigationLink(destination: contactSettings(for: index)) {
-                        EntryListView(entry: .constant(item.entry), index: .constant(index), previewState: previewState)
+    init(entry: ContactTrickEntry, state: ContactTrick.StateModel) {
+        self.state = state
+        _contactTrickEntry = State(initialValue: entry)
+    }
+
+    var body: some View {
+        Form {
+            TextField("Name", text: $contactTrickEntry.name)
+            Section(header: Text("Layout")) {
+                Toggle("Dark Mode", isOn: $contactTrickEntry.darkMode)
+                Picker("Layout", selection: $contactTrickEntry.layout) {
+                    ForEach(ContactTrickLayout.allCases, id: \.id) { layout in
+                        Text(layout.displayName).tag(layout)
                     }
-                    .moveDisabled(true)
                 }
-                .onDelete(perform: onDelete)
+                .pickerStyle(SegmentedPickerStyle())
             }
-        }
 
-        private var addButton: some View {
-            AnyView(Button(action: onAdd) { Text("Add") })
-        }
-
-        func onAdd() {
-            state.add()
-        }
-
-        func onRequestContactsAccess() {
-            contactStore.requestAccess(for: .contacts) { _, _ in
-                DispatchQueue.main.async {
-                    authorization = CNContactStore.authorizationStatus(for: .contacts)
+            Section(header: Text("Primary Value")) {
+                Picker("Primary", selection: $contactTrickEntry.primary) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
+                    }
                 }
             }
-        }
-
-        private func onDelete(offsets: IndexSet) {
-            state.remove(atOffsets: offsets)
-        }
-    }
-
-    struct EntryListView: View {
-        @Binding var entry: ContactTrickEntry
-        @Binding var index: Int
-        @State private var refreshKey = UUID()
-        let previewState: ContactTrickState
 
-        var body: some View {
-            HStack {
-                VStack(alignment: .leading) {
-                    GeometryReader { geometry in
-                        ZStack {
-                            Circle()
-                                .fill(entry.darkMode ? .black : .white)
-                                .foregroundColor(.white)
-                            Image(uiImage: ContactPicture.getImage(contact: entry, state: previewState))
-                                .resizable()
-                                .aspectRatio(1, contentMode: .fit)
-                                .frame(width: geometry.size.height, height: geometry.size.height)
-                                .clipShape(Circle())
-                            Circle()
-                                .stroke(lineWidth: 2)
-                                .foregroundColor(.white)
-                        }
-                        .frame(width: geometry.size.height, height: geometry.size.height)
+            Section(header: Text("Additional Values")) {
+                Picker("Top Value", selection: $contactTrickEntry.top) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
                     }
                 }
-                .fixedSize(horizontal: true, vertical: false)
-                .padding(.horizontal, 30)
-
-                Spacer()
 
-                VStack {
-                    Text("Contact: Trio \(index + 1)").bold()
-//                    HStack {
-//                        Text("Layout: \(entry.layout.displayName)")
-//                        Text("\(entry.ring.displayName)")
-//                        if entry.layout == .single {
-//                            Text("\(entry.primary.displayName)")
-//                        }
-//                        Text("\(entry.top.displayName), \(entry.bottom.displayName)")
-//                    }.foregroundStyle(.secondary)
-//                    HStack {
-//                        Text("Font Size \(entry.fontSize.displayName)")
-//                        Text("Font Width \(entry.fontWidth.displayName)")
-//                        Text("Font Weight \(entry.fontWeight.displayName)")
-//                    }.foregroundStyle(.secondary)
+                Picker("Bottom Value", selection: $contactTrickEntry.bottom) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
+                    }
                 }
             }
-            .frame(maxWidth: .infinity)
-        }
-    }
 
-    struct EntryView: View {
-        @Binding var entry: ContactTrickEntry
-        @State private var availableFonts: [String]? = nil
-        let previewState: ContactTrickState
-
-        private let ringWidths: [Int] = [5, 10, 15]
-        private let ringGaps: [Int] = [0, 2, 4]
-
-        var body: some View {
-            VStack {
-                Section {
-                    HStack {
-                        ZStack {
-                            Circle()
-                                .fill(entry.darkMode ? .black : .white)
-                            Image(uiImage: ContactPicture.getImage(contact: entry, state: previewState))
-                                .resizable()
-                                .aspectRatio(1, contentMode: .fit)
-                                .frame(width: 64, height: 64)
-                                .clipShape(Circle())
-                            Circle()
-                                .stroke(lineWidth: 2)
-                                .foregroundColor(.white)
-                        }
-                        .frame(width: 64, height: 64)
+            Section(header: Text("Ring Settings")) {
+                Picker("Ring Width", selection: $contactTrickEntry.ringWidth) {
+                    ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
+                        Text(width.displayName)
+                            .tag(width)
                     }
                 }
 
-                Form {
-                    Section {
-                        Picker(
-                            selection: $entry.layout,
-                            label: Text("Layout")
-                        ) {
-                            ForEach(ContactTrickLayout.allCases, id: \.self) { layout in
-                                Text(layout.displayName).tag(layout)
-                            }
-                        }
-                    }
-
-                    layoutSpecificSection
-
-                    Section(header: Text("Ring")) {
-                        Picker(
-                            selection: $entry.ring,
-                            label: Text("Outer")
-                        ) {
-                            ForEach(ContactTrickLargeRing.allCases, id: \.self) { ring in
-                                Text(ring.displayName).tag(ring)
-                            }
-                        }
-
-                        if entry.ring != .none {
-                            Picker(
-                                selection: $entry.ringWidth,
-                                label: Text("Width")
-                            ) {
-                                ForEach(
-                                    [
-                                        ContactTrickEntry.RingWidth.tiny,
-                                        ContactTrickEntry.RingWidth.small,
-                                        ContactTrickEntry.RingWidth.regular,
-                                        ContactTrickEntry.RingWidth.medium,
-                                        ContactTrickEntry.RingWidth.large
-                                    ],
-                                    id: \.self
-                                ) { width in
-                                    Text(width.displayName).tag(width)
-                                }
-                            }
-                            Picker(
-                                selection: $entry.ringGap,
-                                label: Text("Gap")
-                            ) {
-                                ForEach(
-                                    [
-                                        ContactTrickEntry.RingGap.tiny,
-                                        ContactTrickEntry.RingGap.small,
-                                        ContactTrickEntry.RingGap.regular,
-                                        ContactTrickEntry.RingGap.medium,
-                                        ContactTrickEntry.RingGap.large
-                                    ],
-                                    id: \.self
-                                ) { gap in
-                                    Text(gap.displayName).tag(gap)
-                                }
-                            }
-                        }
-                    }
-
-                    Section(header: Text("Font")) {
-                        Picker(
-                            selection: $entry.fontSize,
-                            label: Text("Size")
-                        ) {
-                            ForEach(
-                                [
-                                    ContactTrickEntry.FontSize.tiny,
-                                    ContactTrickEntry.FontSize.small,
-                                    ContactTrickEntry.FontSize.regular,
-                                    ContactTrickEntry.FontSize.large
-                                ],
-                                id: \.self
-                            ) { size in
-                                Text(size.displayName).tag(size)
-                            }
-                        }
-                        Picker(
-                            selection: $entry.secondaryFontSize,
-                            label: Text("Secondary size")
-                        ) {
-                            ForEach(
-                                [
-                                    ContactTrickEntry.FontSize.tiny,
-                                    ContactTrickEntry.FontSize.small,
-                                    ContactTrickEntry.FontSize.regular,
-                                    ContactTrickEntry.FontSize.large
-                                ],
-                                id: \.self
-                            ) { size in
-                                Text(size.displayName).tag(size)
-                            }
-                        }
-                        Picker(
-                            selection: $entry.fontWidth,
-                            label: Text("Width")
-                        ) {
-                            ForEach(
-                                [Font.Width.standard, Font.Width.condensed, Font.Width.expanded],
-                                id: \.self
-                            ) { width in
-                                Text(width.displayName).tag(width)
-                            }
-                        }
-                        Picker(
-                            selection: $entry.fontWeight,
-                            label: Text("Weight")
-                        ) {
-                            ForEach(
-                                [Font.Weight.light, Font.Weight.regular, Font.Weight.medium, Font.Weight.bold, Font.Weight.black],
-                                id: \.self
-                            ) { weight in
-                                Text(weight.displayName).tag(weight)
-                            }
-                        }
-                    }
-
-                    Section {
-                        Toggle("Dark mode", isOn: $entry.darkMode)
+                Picker("Ring Gap", selection: $contactTrickEntry.ringGap) {
+                    ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
+                        Text(gap.displayName)
+                            .tag(gap)
                     }
                 }
             }
         }
-
-        private var layoutSpecificSection: some View {
-            Section {
-                if entry.layout == .single {
-                    Picker(
-                        selection: $entry.primary,
-                        label: Text("Primary")
-                    ) {
-                        ForEach(ContactTrickValue.allCases, id: \.self) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                    Picker(
-                        selection: $entry.top,
-                        label: Text("Top")
-                    ) {
-                        ForEach(ContactTrickValue.allCases, id: \.self) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                    Picker(
-                        selection: $entry.bottom,
-                        label: Text("Bottom")
-                    ) {
-                        ForEach(ContactTrickValue.allCases, id: \.self) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                } else if entry.layout == .split {
-                    Picker(
-                        selection: $entry.top,
-                        label: Text("Top")
-                    ) {
-                        ForEach(ContactTrickValue.allCases, id: \.self) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                    Picker(
-                        selection: $entry.bottom,
-                        label: Text("Bottom")
-                    ) {
-                        ForEach(ContactTrickValue.allCases, id: \.self) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                }
+        .navigationBarTitle("Edit Contact Trick", displayMode: .inline)
+        .navigationBarItems(
+            trailing: Button("Save") {
+                saveChanges()
             }
+        )
+    }
+
+    private func saveChanges() {
+        Task {
+            await state.updateContact(entry: contactTrickEntry, newName: contactTrickEntry.name)
+            dismiss()
         }
     }
 }

+ 5 - 5
FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 import Swinject
 
-struct WatchConfigAppleWatchView: View {
+struct WatchConfigAppleWatchView: BaseView {
     let resolver: Resolver
     @ObservedObject var state: WatchConfig.StateModel
 
@@ -101,12 +101,12 @@ struct WatchConfigAppleWatchView: View {
                 content: {
                     VStack {
                         HStack {
-                            NavigationLink(
-                                "Contact Image",
-                                destination: ContactTrick.RootView(resolver: resolver)
-                            ).foregroundStyle(Color.accentColor)
+                            NavigationLink("contact image") {
+                                ContactTrick.RootView(resolver: resolver)
+                            }.foregroundStyle(Color.accentColor)
                         }
                     }
+
 //                        .padding(.bottom)
                 }
             ).listRowBackground(Color.chart)

+ 0 - 865
FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift

@@ -1,865 +0,0 @@
-import Foundation
-import SwiftUI
-
-struct ContactPicture: View {
-    private enum Config {
-        static let lag: TimeInterval = 30
-    }
-
-    @Binding var contact: ContactTrickEntry
-    @Binding var state: ContactTrickState
-
-    private static let formatter: DateFormatter = {
-        let formatter = DateFormatter()
-        formatter.dateFormat = "HH:mm"
-        return formatter
-    }()
-
-    static func getImage(
-        contact: ContactTrickEntry,
-        state: ContactTrickState
-    ) -> UIImage {
-        let width = 1024.0
-        let height = 1024.0
-        var rect = CGRect(x: 0, y: 0, width: width, height: height)
-        let textColor: Color = contact.darkMode ?
-            Color(red: 250 / 256, green: 250 / 256, blue: 250 / 256) :
-            Color(red: 20 / 256, green: 20 / 256, blue: 20 / 256)
-        let secondaryTextColor: Color = contact.darkMode ?
-            Color(red: 220 / 256, green: 220 / 256, blue: 220 / 256) :
-            Color(red: 40 / 256, green: 40 / 256, blue: 40 / 256)
-        let fontWeight = contact.fontWeight
-
-        UIGraphicsBeginImageContext(rect.size)
-        if let context = UIGraphicsGetCurrentContext() {
-            context.setShouldAntialias(true)
-            context.setAllowsAntialiasing(true)
-        }
-
-        let ringWidth = Double(contact.ringWidth.rawValue) / 100.0
-        let ringGap = Double(contact.ringGap.rawValue) / 100.0
-        let outerGap = 0.03
-
-        if contact.ring != .none {
-            rect = CGRect(
-                x: rect.minX + width * outerGap,
-                y: rect.minY + height * outerGap,
-                width: rect.width - width * outerGap * 2,
-                height: rect.height - height * outerGap * 2
-            )
-
-            let ringRect = CGRect(
-                x: rect.minX + width * ringWidth * 0.5,
-                y: rect.minY + height * ringWidth * 0.5,
-                width: rect.width - width * ringWidth,
-                height: rect.height - height * ringWidth
-            )
-
-            drawRing(ring: contact.ring, contact: contact, state: state, rect: ringRect, strokeWidth: width * ringWidth)
-
-            rect = CGRect(
-                x: rect.minX + width * (ringWidth + ringGap),
-                y: rect.minY + height * (ringWidth + ringGap),
-                width: rect.width - width * (ringWidth + ringGap) * 2,
-                height: rect.height - height * (ringWidth + ringGap) * 2
-            )
-        }
-
-        switch contact.layout {
-        case .single:
-            let showTop = contact.top != .none
-            let showBottom = contact.bottom != .none
-
-            let centerX = rect.minX + rect.width / 2
-            let centerY = rect.minY + rect.height / 2
-            let radius = min(rect.width, rect.height) / 2
-
-            var primaryHeight = radius * 0.8
-            let topHeight = radius * 0.5
-            var bottomHeight = radius * 0.5
-
-            var primaryY = centerY - primaryHeight / 2
-
-            if contact.bottom == .none, contact.top != .none {
-                primaryY += radius * 0.2
-            }
-            if contact.bottom != .none, contact.top == .none {
-                primaryY -= radius * 0.2
-            }
-
-            let topY = primaryY - topHeight
-            var bottomY = primaryY + primaryHeight
-
-            let primaryWidth = 2 * sqrt(radius * radius - (primaryHeight * 0.5) * (primaryHeight * 0.5))
-            let topWidth = 2 *
-                sqrt(radius * radius - (topHeight + primaryHeight * 0.5) * (topHeight + primaryHeight * 0.5))
-            var bottomWidth = 2 *
-                sqrt(radius * radius - (bottomHeight + primaryHeight * 0.5) * (bottomHeight + primaryHeight * 0.5))
-
-            if contact.bottom != .none, contact.top == .none {
-                // move things around a little bit to give more space to the bottom area
-                if contact.ring == .iob || contact.ring == .cob || contact.ring == .iobcob ||
-                    (contact.bottom == .trend && contact.ring == .loop)
-                {
-                    bottomHeight = bottomHeight + height * ringWidth * 2
-                    bottomWidth = bottomWidth + width * ringWidth * 2
-                } else if contact.ring == .loop {
-                    primaryHeight = primaryHeight - height * ringWidth
-                    bottomY = primaryY + primaryHeight
-                    bottomHeight = bottomHeight + height * ringWidth * 2
-                    bottomWidth = bottomWidth + width * ringWidth * 2
-                }
-            }
-
-            let primaryRect = (showTop || showBottom) ? CGRect(
-                x: centerX - primaryWidth * 0.5,
-                y: primaryY,
-                width: primaryWidth,
-                height: primaryHeight
-            ) : rect
-            let topRect = CGRect(
-                x: centerX - topWidth * 0.5,
-                y: topY,
-                width: topWidth,
-                height: topHeight
-            )
-            let bottomRect = CGRect(
-                x: centerX - bottomWidth * 0.5,
-                y: bottomY,
-                width: bottomWidth,
-                height: bottomHeight
-            )
-            let secondaryFontSize = contact.secondaryFontSize
-
-            displayPiece(
-                value: contact.primary,
-                contact: contact,
-                state: state,
-                rect: primaryRect,
-                fitHeigh: false,
-                fontSize: contact.fontSize.rawValue,
-                fontWeight: fontWeight,
-                fontWidth: contact.fontWidth,
-                color: textColor
-            )
-            if showTop {
-                displayPiece(
-                    value: contact.top,
-                    contact: contact,
-                    state: state,
-                    rect: topRect,
-                    fitHeigh: true,
-                    fontSize: secondaryFontSize.rawValue,
-                    fontWeight: fontWeight,
-                    fontWidth: contact.fontWidth,
-                    color: secondaryTextColor
-                )
-            }
-            if showBottom {
-                displayPiece(
-                    value: contact.bottom,
-                    contact: contact,
-                    state: state,
-                    rect: bottomRect,
-                    fitHeigh: true,
-                    fontSize: secondaryFontSize.rawValue,
-                    fontWeight: fontWeight,
-                    fontWidth: contact.fontWidth,
-                    color: secondaryTextColor
-                )
-            }
-
-        case .split:
-            let centerX = rect.origin.x + rect.size.width / 2
-            let centerY = rect.origin.y + rect.size.height / 2
-            let radius = min(rect.size.width, rect.size.height) / 2
-
-            let rectangleHeight = radius * sqrt(2) / 2
-            let rectangleWidth = sqrt(2) * radius
-
-            let topY = centerY - rectangleHeight
-            let bottomY = centerY
-
-            let topRect = CGRect(
-                x: centerX - rectangleWidth / 2,
-                y: topY,
-                width: rectangleWidth,
-                height: rectangleHeight
-            )
-            let bottomRect = CGRect(
-                x: centerX - rectangleWidth / 2,
-                y: bottomY,
-                width: rectangleWidth,
-                height: rectangleHeight
-            )
-            let topFontSize = contact.fontSize
-            let bottomFontSize = contact.secondaryFontSize
-
-            displayPiece(
-                value: contact.top,
-                contact: contact,
-                state: state,
-                rect: topRect,
-                fitHeigh: true,
-                fontSize: topFontSize.rawValue,
-                fontWeight: fontWeight,
-                fontWidth: contact.fontWidth,
-                color: textColor
-            )
-            displayPiece(
-                value: contact.bottom,
-                contact: contact,
-                state: state,
-                rect: bottomRect,
-                fitHeigh: true,
-                fontSize: bottomFontSize.rawValue,
-                fontWeight: fontWeight,
-                fontWidth: contact.fontWidth,
-                color: textColor
-            )
-        }
-        let image = UIGraphicsGetImageFromCurrentImageContext()
-        UIGraphicsEndImageContext()
-        return image ?? UIImage()
-    }
-
-    private static func displayPiece(
-        value: ContactTrickValue,
-        contact: ContactTrickEntry,
-        state: ContactTrickState,
-        rect: CGRect,
-        fitHeigh: Bool,
-        fontSize: Int,
-        fontWeight: Font.Weight,
-        fontWidth: Font.Width,
-        color: Color
-    ) {
-        guard value != .none else { return }
-        if value == .ring {
-            drawRing(
-                ring: .loop,
-                contact: contact,
-                state: state,
-                rect: CGRect(
-                    x: rect.minX + rect.width * 0.10,
-                    y: rect.minY + rect.height * 0.10,
-                    width: rect.width * 0.80,
-                    height: rect.height * 0.80
-                ),
-                strokeWidth: 10.0
-            )
-            return
-        }
-        let text: String? = switch value {
-        case .glucose: state.glucose
-        case .eventualBG: state.eventualBG
-        case .delta: state.delta
-        case .trend: state.trend
-        case .lastLoopDate: state.lastLoopDate.map({ formatter.string(from: $0) })
-        case .cob: state.cobText
-        case .iob: state.iobText
-        default: nil
-        }
-
-        let textColor: Color = switch value {
-        case .cob: .loopYellow
-        default: color
-        }
-
-        if let text = text {
-            drawText(
-                text: text,
-                rect: rect,
-                fitHeigh: fitHeigh,
-                fontSize: fontSize,
-                fontWeight: fontWeight,
-                fontWidth: fontWidth,
-                color: textColor
-            )
-        }
-    }
-
-    private static func drawText(
-        text: String,
-        rect: CGRect,
-        fitHeigh: Bool,
-        fontSize: Int,
-        fontWeight: Font.Weight,
-        fontWidth: Font.Width,
-        color: Color
-    ) {
-        var theFontSize = fontSize
-
-        func makeAttributes(_ size: Int) -> [NSAttributedString.Key: Any] {
-            let font = UIFont.systemFont(ofSize: CGFloat(size), weight: fontWeight.uiFontWeight)
-            return [
-                .font: font,
-                .foregroundColor: UIColor(color),
-                .kern: fontWidth.value * Double(fontSize) // `kern` is the correct key for tracking
-            ]
-        }
-
-        var attributes: [NSAttributedString.Key: Any] = makeAttributes(theFontSize)
-
-        var stringSize = text.size(withAttributes: attributes)
-        while stringSize.width > rect.width * 0.90 || fitHeigh && (stringSize.height > rect.height * 0.95), theFontSize > 50 {
-            theFontSize -= 10
-            attributes = makeAttributes(theFontSize)
-            stringSize = text.size(withAttributes: attributes)
-        }
-
-        text.draw(
-            in: CGRect(
-                x: rect.minX + (rect.width - stringSize.width) / 2,
-                y: rect.minY + (rect.height - stringSize.height) / 2,
-                width: stringSize.width,
-                height: stringSize.height
-            ),
-            withAttributes: attributes
-        )
-    }
-
-    private static func drawRing(
-        ring: ContactTrickLargeRing,
-        contact: ContactTrickEntry,
-        state: ContactTrickState,
-        rect: CGRect,
-        strokeWidth: Double
-    ) {
-        guard let context = UIGraphicsGetCurrentContext() else {
-            return
-        }
-        switch ring {
-        case .loop:
-            let color = ringColor(contact: contact, state: state)
-
-            let strokeWidth = strokeWidth
-            let center = CGPoint(x: rect.midX, y: rect.midY)
-            let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2
-
-            context.setLineWidth(strokeWidth)
-            context.setStrokeColor(UIColor(color).cgColor)
-
-            context.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: false)
-
-            context.strokePath()
-        case .iob:
-            if let iob = state.iob, state.maxIOB > 0.1 {
-                drawProgressBar(
-                    rect: rect,
-                    progress: Double(iob) / Double(state.maxIOB),
-                    colors: [contact.darkMode ? .blue : .blue, contact.darkMode ? .pink : .red],
-                    strokeWidth: strokeWidth
-                )
-            }
-        case .cob:
-            if let cob = state.cob, state.maxCOB > 0.01 {
-                drawProgressBar(
-                    rect: rect,
-                    progress: Double(cob) / Double(state.maxCOB),
-                    colors: [.loopYellow, .red],
-                    strokeWidth: strokeWidth
-                )
-            }
-        case .iobcob:
-            if state.maxIOB > 0.01, state.maxCOB > 0.01 {
-                drawDoubleProgressBar(
-                    rect: rect,
-                    progress1: state.iob.map { Double($0) / Double(state.maxIOB) },
-                    progress2: state.cob.map { Double($0) / Double(state.maxCOB) },
-                    colors1: [contact.darkMode ? .blue : .blue, contact.darkMode ? .pink : .red],
-                    colors2: [.loopYellow, .red],
-                    strokeWidth: strokeWidth
-                )
-            }
-        default:
-            break
-        }
-    }
-
-    private static func drawProgressBar(
-        rect: CGRect,
-        progress: Double,
-        colors: [Color],
-        strokeWidth: Double
-    ) {
-        let startAngle: CGFloat = -(.pi + .pi / 4.0)
-        let endAngle: CGFloat = .pi / 4.0
-
-        drawGradientArc(
-            rect: rect,
-            progress: progress,
-            colors: colors,
-            strokeWidth: strokeWidth,
-            startAngle: startAngle,
-            endAngle: endAngle,
-            gradientDirection: .leftToRight
-        )
-    }
-
-    private static func drawDoubleProgressBar(
-        rect: CGRect,
-        progress1: Double?,
-        progress2: Double?,
-        colors1: [Color],
-        colors2: [Color],
-        strokeWidth: Double
-    ) {
-        if let progress1 = progress1 {
-            let startAngle1: CGFloat = .pi / 2 + .pi / 5
-            let endAngle1: CGFloat = 3 * .pi / 2 - .pi / 5
-            drawGradientArc(
-                rect: rect,
-                progress: progress1,
-                colors: colors1,
-                strokeWidth: strokeWidth,
-                startAngle: startAngle1,
-                endAngle: endAngle1,
-                gradientDirection: .bottomToTop
-            )
-        }
-        if let progress2 = progress2 {
-            let startAngle2: CGFloat = .pi / 2 - .pi / 5
-            let endAngle2: CGFloat = -.pi / 2 + .pi / 5
-            drawGradientArc(
-                rect: rect,
-                progress: progress2,
-                colors: colors2,
-                strokeWidth: strokeWidth,
-                startAngle: startAngle2,
-                endAngle: endAngle2,
-                gradientDirection: .bottomToTop
-            )
-        }
-    }
-
-    private static func drawGradientArc(
-        rect: CGRect,
-        progress: Double,
-        colors: [Color],
-        strokeWidth: Double,
-        startAngle: Double,
-        endAngle: Double,
-        gradientDirection: GradientDirection
-    ) {
-        guard let context = UIGraphicsGetCurrentContext() else {
-            return
-        }
-
-        let colors = colors.map { c in UIColor(c).cgColor }
-        let locations: [CGFloat] = [0.0, 1.0]
-        guard let gradient = CGGradient(
-            colorsSpace: CGColorSpaceCreateDeviceRGB(),
-            colors: colors as CFArray,
-            locations: locations
-        ) else {
-            return
-        }
-
-        context.saveGState()
-
-        let center = CGPoint(x: rect.midX, y: rect.midY)
-        let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2
-
-        // angle - The angle to the starting point of the arc, measured in radians from the positive x-axis.
-
-        context.setLineWidth(strokeWidth)
-        context.setLineCap(.round)
-
-        let circumference = 2 * .pi * radius
-        let offsetAngle = (strokeWidth / circumference * 1.1) * 2 * .pi
-
-        let (start, middle, end) = if startAngle > endAngle {
-            (
-                endAngle,
-                startAngle - (startAngle - endAngle) * max(min(progress, 1.0), 0.0),
-                startAngle
-            )
-        } else {
-            (
-                startAngle,
-                startAngle + (endAngle - startAngle) * max(min(progress, 1.0), 0.0),
-                endAngle
-            )
-        }
-
-        if start < middle - offsetAngle {
-            let arcPath1 = UIBezierPath()
-            arcPath1.addArc(
-                withCenter: center,
-                radius: radius,
-                startAngle: start,
-                endAngle: middle - offsetAngle,
-                clockwise: true
-            )
-            context.addPath(arcPath1.cgPath)
-        }
-
-        if middle + offsetAngle < end {
-            let arcPath2 = UIBezierPath()
-            arcPath2.addArc(
-                withCenter: center,
-                radius: radius,
-                startAngle: middle + offsetAngle,
-                endAngle: end,
-                clockwise: true
-            )
-            context.addPath(arcPath2.cgPath)
-        }
-
-        context.replacePathWithStrokedPath()
-        context.clip()
-
-        switch gradientDirection {
-        case .bottomToTop:
-            context.drawLinearGradient(
-                gradient,
-                start: CGPoint(x: rect.midX, y: rect.maxY),
-                end: CGPoint(x: rect.midX, y: rect.minY),
-                options: []
-            )
-
-        case .leftToRight:
-            context.drawLinearGradient(
-                gradient,
-                start: CGPoint(x: rect.minX, y: rect.midY),
-                end: CGPoint(x: rect.maxX, y: rect.midY),
-                options: []
-            )
-        }
-        context.resetClip()
-
-        let circleCenter = CGPoint(
-            x: center.x + radius * cos(middle),
-            y: center.y + radius * sin(middle)
-        )
-
-        context.setLineWidth(strokeWidth * 0.7)
-        context.setStrokeColor(UIColor.white.cgColor)
-        context.addArc(
-            center: circleCenter,
-            radius: 0,
-            startAngle: 0,
-            endAngle: .pi * 2,
-            clockwise: true
-        )
-        context.strokePath()
-
-        context.restoreGState()
-    }
-
-    private static func ringColor(
-        contact _: ContactTrickEntry,
-        state: ContactTrickState
-    ) -> Color {
-        guard let lastLoopDate = state.lastLoopDate else {
-            return .loopGray
-        }
-        let delta = Date().timeIntervalSince(lastLoopDate) - Config.lag
-
-        if delta <= 5.minutes.timeInterval {
-            return .loopGreen
-        } else if delta <= 10.minutes.timeInterval {
-            return .loopYellow
-        } else {
-            return .loopRed
-        }
-    }
-
-    var uiImage: UIImage {
-        ContactPicture.getImage(contact: contact, state: state)
-    }
-
-    var body: some View {
-        Image(uiImage: uiImage)
-            .frame(width: 256, height: 256)
-    }
-}
-
-extension Font.Weight {
-    var uiFontWeight: UIFont.Weight {
-        switch self {
-        case .ultraLight: return .ultraLight
-        case .thin: return .thin
-        case .light: return .light
-        case .regular: return .regular
-        case .medium: return .medium
-        case .semibold: return .semibold
-        case .bold: return .bold
-        case .heavy: return .heavy
-        case .black: return .black
-        default: return .regular
-        }
-    }
-}
-
-enum GradientDirection: Int {
-    case leftToRight
-    case bottomToTop
-}
-
-struct ContactPicturePreview: View {
-    @Binding var contact: ContactTrickEntry
-    @Binding var state: ContactTrickState
-
-    var body: some View {
-        ZStack {
-            ContactPicture(contact: $contact, state: $state)
-            Circle()
-                .stroke(lineWidth: 20)
-                .foregroundColor(.white)
-        }
-        .frame(width: 256, height: 256)
-        .clipShape(Circle())
-        .preferredColorScheme($contact.wrappedValue.darkMode ? .dark : .light)
-    }
-}
-
-struct ContactPicture_Previews: PreviewProvider {
-    struct Preview: View {
-        @State var rangeIndicator: Bool = true
-        @State var darkMode: Bool = true
-        @State var fontSize: ContactTrickEntry.FontSize = .small
-        @State var fontWeight: UIFont.Weight = .bold
-        @State var fontName: String? = "AmericanTypewriter"
-
-        var body: some View {
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        primary: .glucose,
-                        top: .delta,
-                        bottom: .trend,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    trend: "↗︎",
-                    delta: "+0.2",
-                    cob: 25,
-                    cobText: "25"
-                ))
-            ).previewDisplayName("bg + trend + delta")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        ring: .iob,
-                        primary: .glucose,
-                        bottom: .trend,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    trend: "↗︎",
-                    iob: 6.1,
-                    iobText: "6.1",
-                    maxIOB: 8.0
-                ))
-            ).previewDisplayName("bg + trend + iob ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        primary: .glucose,
-                        top: .ring,
-                        bottom: .trend,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    trend: "↗︎",
-                    lastLoopDate: .now
-                ))
-
-            ).previewDisplayName("bg + trend + ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        ring: .loop,
-                        primary: .glucose,
-                        top: .none,
-                        bottom: .trend,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "8.8",
-                    trend: "→",
-                    lastLoopDate: .now
-                ))
-            ).previewDisplayName("bg + trend + ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        ring: .loop,
-                        primary: .glucose,
-                        top: .none,
-                        bottom: .eventualBG,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    lastLoopDate: .now - 7.minutes,
-                    eventualBG: "6.2"
-                ))
-            ).previewDisplayName("bg + eventual + ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        ring: .loop,
-                        primary: .lastLoopDate,
-                        top: .none,
-                        bottom: .none,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    trend: "↗︎",
-                    lastLoopDate: .now - 2.minutes
-                ))
-            ).previewDisplayName("lastLoopDate + ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        ring: .loop,
-                        primary: .glucose,
-                        top: .none,
-                        bottom: .none,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    lastLoopDate: .now,
-                    iob: 6.1,
-                    iobText: "6.1",
-                    maxIOB: 8.0
-                ))
-            ).previewDisplayName("bg + ring + ring2")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        layout: .split,
-                        top: .iob,
-                        bottom: .cob,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    iob: 1.5,
-                    iobText: "1.5",
-                    cob: 25,
-                    cobText: "25"
-                ))
-            ).previewDisplayName("iob + cob")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        layout: .single,
-                        ring: .iobcob,
-                        primary: .none,
-                        ringWidth: .regular,
-                        ringGap: .regular,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    iob: 1,
-                    iobText: "5.5",
-                    cob: 25,
-                    cobText: "25",
-                    maxIOB: 10,
-                    maxCOB: 120
-                ))
-            ).previewDisplayName("iobcob ring")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        layout: .single,
-                        ring: .iobcob,
-                        primary: .none,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    iob: -0.2,
-                    iobText: "0.0",
-                    cob: 0,
-                    cobText: "0",
-                    maxIOB: 10,
-                    maxCOB: 120
-                ))
-            ).previewDisplayName("iobcob ring (0/0)")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        layout: .single,
-                        ring: .iobcob,
-                        primary: .none,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    iob: 10,
-                    iobText: "0.0",
-                    cob: 120,
-                    cobText: "0",
-                    maxIOB: 10,
-                    maxCOB: 120
-                ))
-            ).previewDisplayName("iobcob ring (max/max)")
-
-            ContactPicturePreview(
-                contact: .constant(
-                    ContactTrickEntry(
-                        layout: .single,
-                        ring: .iobcob,
-                        primary: .glucose,
-                        bottom: .trend,
-                        fontSize: fontSize,
-                        fontWeight: .medium
-                    )
-                ),
-                state: .constant(ContactTrickState(
-                    glucose: "6.8",
-                    trend: "↗︎",
-                    iob: 5.5,
-                    iobText: "5.5",
-                    cob: 25,
-                    cobText: "25",
-                    maxIOB: 10,
-                    maxCOB: 120
-                ))
-            ).previewDisplayName("bg + trend + iobcob ring")
-        }
-    }
-
-    static var previews: some View {
-        Preview()
-    }
-}

+ 0 - 450
FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift

@@ -1,450 +0,0 @@
-import Algorithms
-import Combine
-import Contacts
-import CoreData
-import Foundation
-import Swinject
-
-protocol ContactTrickManager {
-    func updateContacts(contacts: [ContactTrickEntry]) async -> Bool
-    var currentContacts: [ContactTrickEntry] { get }
-}
-
-final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
-    private var state = ContactTrickState()
-    private let contactStore = CNContactStore()
-
-    @Injected() private var broadcaster: Broadcaster!
-    @Injected() private var settingsManager: SettingsManager!
-    @Injected() private var apsManager: APSManager!
-    @Injected() private var contactTrickStorage: ContactTrickStorage!
-    @Injected() private var carbsStorage: CarbsStorage!
-    @Injected() private var tempTargetsStorage: TempTargetsStorage!
-    @Injected() private var glucoseStorage: GlucoseStorage!
-
-    private var glucoseFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 0
-        if settingsManager.settings.units == .mmolL {
-            formatter.minimumFractionDigits = 1
-            formatter.maximumFractionDigits = 1
-        }
-        formatter.roundingMode = .halfUp
-        return formatter
-    }
-
-    private var eventualFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 1
-        return formatter
-    }
-
-    private var deltaFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = settingsManager.settings.units == .mmolL ? 1 : 0
-        formatter.positivePrefix = "+"
-        formatter.negativePrefix = "-"
-        return formatter
-    }
-
-    private var targetFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 1
-        return formatter
-    }
-
-    let context = CoreDataStack.shared.newTaskContext()
-    let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-
-    private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
-    private var subscriptions = Set<AnyCancellable>()
-
-    private var lifetime = Lifetime()
-
-    init(resolver: Resolver) {
-        super.init()
-        injectServices(resolver)
-        registerHandlers()
-        registerSubscribers()
-
-        coreDataPublisher =
-            changedObjectsOnManagedObjectContextDidSavePublisher()
-                .receive(on: DispatchQueue.global(qos: .background))
-                .share()
-                .eraseToAnyPublisher()
-
-        Task {
-            contacts = await contactTrickStorage.fetchContactTrickEntries()
-        }
-
-        knownIds = contacts.compactMap(\.contactId)
-
-        Task {
-            await configureContactTrickState()
-        }
-
-        broadcaster.register(SettingsObserver.self, observer: self)
-    }
-
-    private func registerSubscribers() {
-        glucoseStorage.updatePublisher
-            .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.configureContactTrickState()
-                }
-            }
-            .store(in: &subscriptions)
-    }
-
-    private func registerHandlers() {
-        coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.configureContactTrickState()
-            }
-        }.store(in: &subscriptions)
-
-        coreDataPublisher?.filterByEntityName("CarbEntryStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.configureContactTrickState()
-            }
-        }.store(in: &subscriptions)
-
-        coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.configureContactTrickState()
-            }
-        }.store(in: &subscriptions)
-    }
-
-    private var knownIds: [String] = []
-    private var contacts: [ContactTrickEntry] = []
-
-    var currentContacts: [ContactTrickEntry] {
-        contacts
-    }
-
-    private func fetchlastDetermination() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: context,
-            predicate: NSPredicate.enactedDetermination,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1
-        )
-
-        return await context.perform {
-            guard let fetchedResults = results as? [OrefDetermination] else { return [] }
-
-            return fetchedResults.map(\.objectID)
-        }
-    }
-
-    private func fetchGlucose() async -> [NSManagedObjectID] {
-        let results = await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateFor120MinAgo,
-            key: "date",
-            ascending: false,
-            fetchLimit: 24,
-            batchSize: 12
-        )
-
-        return await context.perform {
-            guard let glucoseResults = results as? [GlucoseStored] else {
-                return []
-            }
-
-            return glucoseResults.map(\.objectID)
-        }
-    }
-
-    @MainActor private func configureContactTrickState() async {
-        let glucoseValuesIds = await fetchGlucose()
-        async let getLatestDeterminationIds = fetchlastDetermination()
-        guard let lastDeterminationId = await getLatestDeterminationIds.first else {
-            debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to get last Determination")
-            return
-        }
-
-        do {
-            let glucoseValues: [GlucoseStored] = await CoreDataStack.shared
-                .getNSManagedObject(with: glucoseValuesIds, context: viewContext)
-            let lastDetermination = try viewContext.existingObject(with: lastDeterminationId) as? OrefDetermination
-
-            await MainActor.run { [weak self] in
-                guard let self = self else { return }
-
-                if let firstGlucoseValue = glucoseValues.first {
-                    let value = self.settingsManager.settings.units == .mgdL
-                        ? Decimal(firstGlucoseValue.glucose)
-                        : Decimal(firstGlucoseValue.glucose).asMmolL
-
-                    self.state.glucose = self.glucoseFormatter.string(from: value as NSNumber)
-                    self.state.trend = firstGlucoseValue.directionEnum?.symbol
-
-                    let delta = glucoseValues.count >= 2
-                        ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseValues.dropFirst().first?.glucose ?? 0)
-                        : 0
-                    let deltaConverted = self.settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
-                    self.state.delta = self.deltaFormatter.string(from: deltaConverted as NSNumber)
-                }
-
-                self.state.lastLoopDate = lastDetermination?.timestamp
-
-                self.state.iob = lastDetermination?.iob as? Decimal
-                if let cobValue = lastDetermination?.cob {
-                    self.state.cob = Decimal(cobValue)
-                } else {
-                    self.state.cob = 0
-                }
-
-                if let eventualBG = self.settingsManager.settings.units == .mgdL ? lastDetermination?
-                    .eventualBG : lastDetermination?
-                    .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
-                {
-                    let eventualBGAsString = self.eventualFormatter.string(from: eventualBG)
-                    self.state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
-                }
-
-//                guard (try? JSONEncoder().encode(state)) != nil else {
-//                    warning(.service, "Cannot encode watch state")
-//                    return
-//                }
-
-                if contacts.isNotEmpty, CNContactStore.authorizationStatus(for: .contacts) == .authorized {
-                    let newContacts = contacts.enumerated()
-                        .map { index, entry in self.renderContact(entry, index + 1, self.state) }
-                    
-                    // TODO: existiert die zurück gegebene contact ID in CD
-                    // wenn ja, eintrag ignorieren
-                    // wemnn nein, eintrag neu einlegen
-                
-                    
-                    if newContacts != contacts {
-                        // when we create new contacts we store the IDs, in that case we need to write into the settings storage
-                        for contactToStore in newContacts {
-                            Task {
-                                await self.contactTrickStorage.storeContactTrickEntry(contactToStore)
-                            }
-                        } }
-                    contacts = newContacts
-                }
-            }
-
-        } catch let error as NSError {
-            debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to configure state with error: \(error)")
-        }
-    }
-
-    func updateContacts(contacts: [ContactTrickEntry]) async -> Bool {
-        self.contacts = contacts
-        let newIds = contacts.compactMap(\.contactId)
-        let knownSet = Set(knownIds)
-        let newSet = Set(newIds)
-        let removedIds = knownSet.subtracting(newSet)
-
-        return await context.perform {
-            do {
-                let fetchRequest: NSFetchRequest<ContactTrickEntryStored> = ContactTrickEntryStored.fetchRequest()
-                fetchRequest.predicate = NSPredicate(format: "contactId IN %@", removedIds)
-                let objectIDsToDelete = try self.viewContext.fetch(fetchRequest).compactMap(\.objectID)
-
-                for objectID in objectIDsToDelete {
-                    Task {
-                        await self.contactTrickStorage.deleteContactTrickEntry(objectID)
-                    }
-                }
-
-                Task {
-                    await self.configureContactTrickState()
-                }
-                self.knownIds = self.contacts.compactMap(\.contactId)
-                return true
-            } catch let error as NSError {
-                debugPrint(
-                    "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to delete Contact Trick Entry: \(error.userInfo)"
-                )
-                return false
-            }
-        }
-    }
-
-    private let keysToFetch = [
-        CNContactImageDataKey,
-        CNContactGivenNameKey,
-        CNContactOrganizationNameKey
-    ] as [CNKeyDescriptor]
-
-    private func renderContact(_ _entry: ContactTrickEntry, _ index: Int, _ state: ContactTrickState) -> ContactTrickEntry {
-        var entry = _entry
-        let mutableContact: CNMutableContact
-        let saveRequest = CNSaveRequest()
-
-        if let contactId = entry.contactId {
-            do {
-                let contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch)
-
-                mutableContact = contact.mutableCopy() as! CNMutableContact
-                updateContactFields(entry: entry, index: index, state: state, mutableContact: mutableContact)
-                saveRequest.update(mutableContact)
-            } catch let error as NSError {
-                if error.code == 200 { // 200: Updated Record Does Not Exist
-                    print("in handleEnabledContact, failed to fetch the contact, code 200, contact does not exist")
-                    mutableContact = createNewContact(
-                        entry: entry,
-                        index: index,
-                        state: state,
-                        saveRequest: saveRequest
-                    )
-                } else {
-                    print("in handleEnabledContact, failed to fetch the contact - \(getContactsErrorDetails(error))")
-                    return entry
-                }
-            } catch {
-                print("in handleEnabledContact, failed to fetch the contact: \(error.localizedDescription)")
-                return entry
-            }
-        } else {
-            print("no contact \(index) - creating")
-            mutableContact = createNewContact(
-                entry: entry,
-                index: index,
-                state: state,
-                saveRequest: saveRequest
-            )
-        }
-
-        saveUpdatedContact(saveRequest)
-
-        entry.contactId = mutableContact.identifier
-
-        return entry
-    }
-
-    private func createNewContact(
-        entry: ContactTrickEntry,
-        index: Int,
-        state: ContactTrickState,
-        saveRequest: CNSaveRequest
-    ) -> CNMutableContact {
-        let mutableContact = CNMutableContact()
-        updateContactFields(
-            entry: entry, index: index, state: state, mutableContact: mutableContact
-        )
-        print("creating a new contact, \(mutableContact.identifier)")
-        saveRequest.add(mutableContact, toContainerWithIdentifier: nil)
-        return mutableContact
-    }
-
-    private func updateContactFields(
-        entry: ContactTrickEntry,
-        index: Int,
-        state: ContactTrickState,
-        mutableContact: CNMutableContact
-    ) {
-        mutableContact.givenName = "Trio \(index)"
-        mutableContact
-            .organizationName =
-            "Created and managed by Trio - \(Date().formatted(date: .abbreviated, time: .shortened))"
-
-        mutableContact.imageData = ContactPicture.getImage(
-            contact: entry,
-            state: state
-        ).pngData()
-    }
-
-    private func deleteContact(_ contactId: String) -> Bool {
-        do {
-            print("deleting contact \(contactId)")
-            let keysToFetch = [CNContactIdentifierKey as CNKeyDescriptor] // we don't really need any, so just ID
-            let contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch)
-
-            guard let mutableContact = contact.mutableCopy() as? CNMutableContact else {
-                print("in deleteContact, failed to get a mutable copy of the contact")
-                return false
-            }
-
-            let saveRequest = CNSaveRequest()
-            saveRequest.delete(mutableContact)
-            try contactStore.execute(saveRequest)
-            return true
-        } catch let error as NSError {
-            if error.code == 200 { // Updated Record Does Not Exist
-                return true
-            } else {
-                print("in deleteContact, failed to update the contact - \(getContactsErrorDetails(error))")
-                return false
-            }
-        } catch {
-            print("in deleteContact, failed to update the contact: \(error.localizedDescription)")
-            return false
-        }
-    }
-
-    private func saveUpdatedContact(_ saveRequest: CNSaveRequest) {
-        do {
-            try contactStore.execute(saveRequest)
-        } catch let error as NSError {
-            print("in updateContact, failed to update the contact - \(getContactsErrorDetails(error))")
-        } catch {
-            print("in updateContact, failed to update the contact: \(error.localizedDescription)")
-        }
-    }
-
-    private func getContactsErrorDetails(_ error: NSError) -> String {
-        var details: String?
-        if error.domain == CNErrorDomain {
-            switch error.code {
-            case CNError.authorizationDenied.rawValue:
-                details = "Authorization denied"
-            case CNError.communicationError.rawValue:
-                details = "Communication error"
-            case CNError.insertedRecordAlreadyExists.rawValue:
-                details = "Record already exists"
-            case CNError.dataAccessError.rawValue:
-                details = "Data access error"
-            default:
-                details = "Code \(error.code)"
-            }
-        }
-        return "\(details ?? "no details"): \(error.localizedDescription)"
-    }
-
-    private func descriptionForTarget(_ target: TempTarget) -> String {
-        let units = settingsManager.settings.units
-
-        var low = target.targetBottom
-        var high = target.targetTop
-        if units == .mmolL {
-            low = low?.asMmolL
-            high = high?.asMmolL
-        }
-
-        let description =
-            "\(targetFormatter.string(from: (low ?? 0) as NSNumber)!) - \(targetFormatter.string(from: (high ?? 0) as NSNumber)!)" +
-            " for \(targetFormatter.string(from: target.duration as NSNumber)!) min"
-
-        return description
-    }
-}
-
-extension BaseContactTrickManager:
-    SettingsObserver
-{
-    func settingsDidChange(_: FreeAPSSettings) {
-        Task {
-            await configureContactTrickState()
-        }
-    }
-}

+ 0 - 15
FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift

@@ -1,15 +0,0 @@
-import Foundation
-
-struct ContactTrickState: Codable {
-    var glucose: String?
-    var trend: String?
-    var delta: String?
-    var lastLoopDate: Date?
-    var iob: Decimal?
-    var iobText: String?
-    var cob: Decimal?
-    var cobText: String?
-    var eventualBG: String?
-    var maxIOB: Decimal = 10.0
-    var maxCOB: Decimal = 120.0
-}

+ 111 - 0
FreeAPS/Sources/Services/ContactTrick/Manager.swift

@@ -0,0 +1,111 @@
+import Contacts
+
+final class ContactManager {
+    private let contactStore = CNContactStore()
+
+    /// Checks if the app has access to the user's contacts.
+    func requestAccess() async -> Bool {
+        await withCheckedContinuation { continuation in
+            contactStore.requestAccess(for: .contacts) { granted, _ in
+                continuation.resume(returning: granted)
+            }
+        }
+    }
+
+    /// Creates a new contact in the Apple contact list.
+    /// - Parameter name: The name of the contact.
+    /// - Returns: The generated `identifier` of the contact, or `nil` if an error occurs.
+    func createContact(name: String) async -> String? {
+        do {
+            let contact = CNMutableContact()
+            contact.givenName = name
+
+            let saveRequest = CNSaveRequest()
+            saveRequest.add(contact, toContainerWithIdentifier: nil)
+
+            try contactStore.execute(saveRequest)
+
+            // Re-fetch the contact to retrieve its `identifier`.
+            let predicate = CNContact.predicateForContacts(matchingName: name)
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            return contacts.first?.identifier // Return the `identifier`.
+        } catch {
+            print("Error creating contact: \(error)")
+            return nil
+        }
+    }
+
+    /// Deletes a contact from the Apple contact list using its `identifier`.
+    /// - Parameter identifier: The unique identifier of the contact.
+    /// - Returns: `true` if the contact was successfully deleted, `false` otherwise.
+    func deleteContact(withIdentifier identifier: String) async -> Bool {
+        do {
+            // Attempt to find the contact using its identifier.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            guard let contact = contacts.first else {
+                print("Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Contact found -> Delete it.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            let deleteRequest = CNSaveRequest()
+            deleteRequest.delete(mutableContact)
+
+            try contactStore.execute(deleteRequest)
+            print("Contact successfully deleted: \(identifier)")
+            return true
+        } catch {
+            print("Error deleting contact: \(error)")
+            return false
+        }
+    }
+
+    /// Updates an existing contact in the Apple contact list.
+    /// - Parameters:
+    ///   - identifier: The unique identifier of the contact.
+    ///   - newName: The new name to assign to the contact.
+    /// - Returns: `true` if the contact was successfully updated, `false` otherwise.
+    func updateContact(withIdentifier identifier: String, newName: String) async -> Bool {
+        do {
+            // Search for the contact using its `identifier`.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactGivenNameKey as CNKeyDescriptor,
+                    CNContactFamilyNameKey as CNKeyDescriptor
+                ]
+            )
+
+            guard let contact = contacts.first else {
+                print("Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Update the contact.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.givenName = newName // Example: Update the given name.
+
+            let updateRequest = CNSaveRequest()
+            updateRequest.update(mutableContact)
+
+            try contactStore.execute(updateRequest)
+            print("Contact successfully updated: \(identifier)")
+            return true
+        } catch {
+            print("Error updating contact: \(error)")
+            return false
+        }
+    }
+}

+ 1 - 0
Model/Classes+Properties/ContactTrickEntryStored+CoreDataProperties.swift

@@ -6,6 +6,7 @@ public extension ContactTrickEntryStored {
         NSFetchRequest<ContactTrickEntryStored>(entityName: "ContactTrickEntryStored")
     }
 
+    @NSManaged var name: String
     @NSManaged var layout: String?
     @NSManaged var ring: String?
     @NSManaged var primary: String?

+ 1 - 0
Model/CoreDataActor.swift

@@ -0,0 +1 @@
+import Foundation

+ 46 - 0
Model/NSModelObjectContextExecutor.swift

@@ -0,0 +1,46 @@
+import CoreData
+import Foundation
+
+public final class NSModelObjectContextExecutor: @unchecked Sendable, SerialExecutor {
+    public let context: NSManagedObjectContext
+
+    public init(context: NSManagedObjectContext) {
+        self.context = context
+    }
+
+    // Enqueue the job to the context's queue.
+    public func enqueue(_ job: consuming ExecutorJob) {
+        let unownedJob = UnownedJob(job)
+        let unownedExecutor = asUnownedSerialExecutor()
+        context.perform {
+            unownedJob.runSynchronously(on: unownedExecutor)
+        }
+    }
+
+    // Return an unowned serial executor reference.
+    public func asUnownedSerialExecutor() -> UnownedSerialExecutor {
+        UnownedSerialExecutor(ordinary: self)
+    }
+}
+
+// A protocol to define common functionalities for Core Data-based actors
+protocol CoreDataActor {
+    var modelExecutor: NSModelObjectContextExecutor { get }
+    var modelContainer: NSPersistentContainer { get }
+}
+
+// Extend the protocol with default implementations and helpers
+extension CoreDataActor {
+    public var modelContext: NSManagedObjectContext {
+        modelExecutor.context
+    }
+
+    public var unownedExecutor: UnownedSerialExecutor {
+        modelExecutor.asUnownedSerialExecutor()
+    }
+
+    // Provide a generic subscript to fetch objects by NSManagedObjectID
+    public subscript<T>(id: NSManagedObjectID, as _: T.Type) -> T? where T: NSManagedObject {
+        try? modelContext.existingObject(with: id) as? T
+    }
+}

+ 3 - 2
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24B91" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24C101" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -35,6 +35,7 @@
         <attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
         <attribute name="isDarkMode" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="layout" optional="YES" attributeType="String"/>
+        <attribute name="name" optional="YES" attributeType="String"/>
         <attribute name="primary" optional="YES" attributeType="String"/>
         <attribute name="ring" optional="YES" attributeType="String"/>
         <attribute name="ringGap" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
@@ -241,4 +242,4 @@
             <fetchIndexElement property="isPreset" type="Binary" order="descending"/>
         </fetchIndex>
     </entity>
-</model>
+</model>