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

Merge branch 'contactTrick' of github.com:polscm32/Trio-dev into add-watch-contact

Deniz Cengiz пре 1 година
родитељ
комит
4126fc3ecf

+ 20 - 12
FreeAPS.xcodeproj/project.pbxproj

@@ -342,6 +342,11 @@
 		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 /* ContactTrickManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */; };
+		BDC531122D1060FA00088832 /* ContactTrickDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */; };
+		BDC531142D10611D00088832 /* AddContactTrickSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531132D10611D00088832 /* AddContactTrickSheet.swift */; };
+		BDC531162D10629000088832 /* ContactTrickPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531152D10629000088832 /* ContactTrickPicture.swift */; };
+		BDC531182D1062F200088832 /* ContactTrickState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC531172D1062F200088832 /* ContactTrickState.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 +547,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 +1042,11 @@
 		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 /* ContactTrickManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickManager.swift; sourceTree = "<group>"; };
+		BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickDetailView.swift; sourceTree = "<group>"; };
+		BDC531132D10611D00088832 /* AddContactTrickSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactTrickSheet.swift; sourceTree = "<group>"; };
+		BDC531152D10629000088832 /* ContactTrickPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickPicture.swift; sourceTree = "<group>"; };
+		BDC531172D1062F200088832 /* ContactTrickState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickState.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 +1249,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>"; };
@@ -2991,6 +2995,8 @@
 			isa = PBXGroup;
 			children = (
 				E592A3712CEEC038009A472C /* ContactTrickRootView.swift */,
+				BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */,
+				BDC531132D10611D00088832 /* AddContactTrickSheet.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -3009,9 +3015,9 @@
 		E592A37E2CEEC046009A472C /* ContactTrick */ = {
 			isa = PBXGroup;
 			children = (
-				E592A37B2CEEC046009A472C /* ContactTrickManager.swift */,
-				E592A37C2CEEC046009A472C /* ContactTrickState.swift */,
-				E592A37D2CEEC046009A472C /* ContactPicture.swift */,
+				BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */,
+				BDC531152D10629000088832 /* ContactTrickPicture.swift */,
+				BDC531172D1062F200088832 /* ContactTrickState.swift */,
 			);
 			path = ContactTrick;
 			sourceTree = "<group>";
@@ -3437,9 +3443,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 */,
@@ -3778,6 +3781,7 @@
 				E592A3772CEEC038009A472C /* ContactTrickStateModel.swift in Sources */,
 				E592A3782CEEC038009A472C /* ContactTrickDataFlow.swift in Sources */,
 				E592A3792CEEC038009A472C /* ContactTrickRootView.swift in Sources */,
+				BDC531182D1062F200088832 /* ContactTrickState.swift in Sources */,
 				E592A37A2CEEC038009A472C /* ContactTrickProvider.swift in Sources */,
 				CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
@@ -3797,6 +3801,7 @@
 				69A31254F2451C20361D172F /* TreatmentsStateModel.swift in Sources */,
 				1967DFC029D053AC00759F30 /* IconSelection.swift in Sources */,
 				19D4E4EB29FC6A9F00351451 /* Charts.swift in Sources */,
+				BDC531162D10629000088832 /* ContactTrickPicture.swift in Sources */,
 				FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */,
 				F90692D3274B9A130037068D /* AppleHealthKitRootView.swift in Sources */,
 				BDF34F852C10C62E00D51995 /* GlucoseData.swift in Sources */,
@@ -3854,6 +3859,8 @@
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
+				BDC530FF2D0F6BE300088832 /* ContactTrickManager.swift in Sources */,
+				BDC531122D1060FA00088832 /* ContactTrickDetailView.swift in Sources */,
 				DDE179552C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift in Sources */,
 				DDE179562C910127003CDDB7 /* BolusStored+CoreDataClass.swift in Sources */,
 				DDE179572C910127003CDDB7 /* BolusStored+CoreDataProperties.swift in Sources */,
@@ -3870,6 +3877,7 @@
 				DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */,
 				DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */,
 				DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */,
+				BDC531142D10611D00088832 /* AddContactTrickSheet.swift in Sources */,
 				DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
 				DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.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)
     }

+ 6 - 3
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 {
@@ -36,7 +39,7 @@ struct ContactTrickEntry: Hashable, Sendable {
         Font.Width.fromString(string)
     }
 
-    enum FontSize: Int, Codable, Sendable {
+    enum FontSize: Int, Codable, Sendable, CaseIterable {
         case tiny = 200
         case small = 250
         case regular = 300
@@ -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 {}

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

@@ -1,79 +1,147 @@
 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!
-
+        @ObservationIgnored @Injected() var contactTrickManager: ContactTrickManager!
+        var contactTrickEntries = [ContactTrickEntry]()
         var units: GlucoseUnits = .mmolL
 
+        var previewState: ContactTrickState {
+            ContactTrickState(
+                glucose: self.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
+            )
+        }
+
+        /// 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 contactTrickManager.requestAccess()
+            guard hasAccess else {
+                debugPrint("\(DebuggingIdentifiers.failed) No access to contacts.")
+                return
+            }
+
+            // 2. Create the contact and retrieve its `identifier`.
+            guard let contactId = await contactTrickManager.createContact(name: name) else {
+                debugPrint("\(DebuggingIdentifiers.failed) 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)
+
+            // 5. Update ContactTrickState and set the image for the newly created contact
+            await contactTrickManager.updateContactTrickState()
+            await contactTrickManager.setImageForContact(contactId: contactId)
         }
 
-        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 {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
+                return
+            }
+
+            // 1. Attempt to delete the contact from Apple Contacts.
+            let contactDeleted = await contactTrickManager.deleteContact(withIdentifier: contactId)
+            if contactDeleted {
+                debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted from Apple Contacts: \(contactId)")
+            } else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to delete contact from Apple Contacts. Check if it exists.")
+            }
+
+            // 2. Delete the entry from Core Data.
+            if let objectID = entry.managedObjectID {
+                await deleteContactTrick(objectID: objectID)
             }
+        }
 
-            let didUpdateStatus = await contactTrickManager.updateContacts(contacts: contacts)
+        /// 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()
+        }
 
-            //            for contact in contacts {
-            //                await contactTrickStorage.storeContactTrickEntry(contact)
-            //            }
+        /// Updates a contact in Apple Contacts and Core Data.
+        /// - Parameters:
+        ///   - entry: The ContactTrickEntry to be updated.
+        func updateContact(with entry: ContactTrickEntry) async {
+            guard let contactId = entry.contactId else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
+                return
+            }
 
-            syncInProgress = didUpdateStatus
-            changed = didUpdateStatus
+            // 1. Update the entry in Core Data.
+            await updateContactTrick(entry)
 
-            if didUpdateStatus {
-                contacts.enumerated().forEach { index, item in
-                    self.items[index].entry = item
-                }
+            // 2. Update the contact in Apple Contacts.
+            
+            /// Update name
+            let contactUpdated = await contactTrickManager
+                .updateContact(withIdentifier: contactId, newName: entry.name) // TODO: - Probably not needed anymore
+            
+            guard contactUpdated else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact.")
+                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)
+            
+            /// Update state and image
+            await contactTrickManager.updateContactTrickState()
+            await contactTrickManager.setImageForContact(contactId: contactId)
+        }
+
+        /// Updates a Core Data entry.
+        /// - Parameter entry: The updated ContactTrickEntry.
+        func updateContactTrick(_ entry: ContactTrickEntry) async {
+            await contactTrickStorage.updateContactTrickEntry(entry)
+            await fetchContactTrickEntriesAndUpdateUI()
         }
     }
 }

+ 181 - 0
FreeAPS/Sources/Modules/ContactTrick/View/AddContactTrickSheet.swift

@@ -0,0 +1,181 @@
+import SwiftUI
+
+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
+    @State private var ring: ContactTrickLargeRing = .none
+    @State private var fontSize: ContactTrickEntry.FontSize = .regular
+    @State private var secondaryFontSize: ContactTrickEntry.FontSize = .small
+    @State private var fontWeight: Font.Weight = .medium
+    @State private var fontWidth: Font.Width = .standard
+
+    private var previewEntry: ContactTrickEntry {
+        ContactTrickEntry(
+            id: UUID(),
+            name: name,
+            layout: layout,
+            ring: ring,
+            primary: primary,
+            top: top,
+            bottom: bottom,
+            contactId: nil, // not needed for preview, gets set later in ContactTrickStateModel via ContactTrickManager
+            darkMode: isDarkMode,
+            ringWidth: ringWidth,
+            ringGap: ringGap,
+            fontSize: fontSize,
+            secondaryFontSize: secondaryFontSize,
+            fontWeight: fontWeight,
+            fontWidth: fontWidth
+        )
+    }
+
+    var body: some View {
+        NavigationView {
+            Form {
+                // TODO: - make this beautiful @Dan
+
+                // Preview Section
+                Section {
+                    HStack {
+                        Spacer()
+                        Image(uiImage: ContactPicture.getImage(contact: previewEntry, state: state.previewState))
+                            .resizable()
+                            .aspectRatio(1, contentMode: .fit)
+                            .frame(width: 100, height: 100)
+                            .clipShape(Circle())
+                            .overlay(Circle().stroke(Color.gray, lineWidth: 1))
+                        Spacer()
+                    }
+                }
+
+                // Name Section
+                TextField("Name", text: $name)
+
+                // Layout Section
+                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())
+                }
+
+                // Primary Value Section
+                Section(header: Text("Primary Value")) {
+                    Picker("Primary", selection: $primary) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
+                    }
+                }
+
+                // Additional Values Section
+                Section(header: Text("Additional Values")) {
+                    Picker("Top Value", selection: $top) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
+                    }
+                    Picker("Bottom Value", selection: $bottom) {
+                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                            Text(value.displayName).tag(value)
+                        }
+                    }
+                }
+
+                // Ring Settings Section
+                Section(header: Text("Ring Settings")) {
+                    Picker("Ring Type", selection: $ring) {
+                        ForEach(ContactTrickLargeRing.allCases, id: \.self) { ring in
+                            Text(ring.displayName).tag(ring)
+                        }
+                    }
+                    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)
+                        }
+                    }
+                }
+
+                // Font Settings Section
+                Section(header: Text("Font Settings")) {
+                    fontSizePicker
+                    secondaryFontSizePicker
+                    fontWeightPicker
+                    fontWidthPicker
+                }
+            }
+            .navigationBarTitle("Add Contact Trick", displayMode: .inline)
+            .navigationBarItems(
+                leading: Button("Cancel") {
+                    dismiss()
+                },
+                trailing: Button("Save") {
+                    saveNewEntry()
+                }
+            )
+        }
+    }
+
+    private var fontSizePicker: some View {
+        Picker("Font Size", selection: $fontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var secondaryFontSizePicker: some View {
+        Picker("Secondary Font Size", selection: $secondaryFontSize) {
+            ForEach(ContactTrickEntry.FontSize.allCases, id: \.self) { size in
+                Text(size.displayName).tag(size)
+            }
+        }
+    }
+
+    private var fontWeightPicker: some View {
+        Picker("Font Weight", selection: $fontWeight) {
+            ForEach(
+                [Font.Weight.light, Font.Weight.regular, Font.Weight.medium, Font.Weight.bold, Font.Weight.black],
+                id: \.self
+            ) { weight in
+                Text("\(weight)".capitalized).tag(weight)
+            }
+        }
+    }
+
+    private var fontWidthPicker: some View {
+        Picker("Font Width", selection: $fontWidth) {
+            ForEach(
+                [Font.Width.standard, Font.Width.condensed, Font.Width.expanded],
+                id: \.self
+            ) { width in
+                Text("\(width)".capitalized).tag(width)
+            }
+        }
+    }
+
+    private func saveNewEntry() {
+        // Save the currently previewed entry
+        Task {
+            await state.createAndSaveContactTrick(entry: previewEntry, name: name)
+            dismiss()
+        }
+    }
+}

+ 92 - 0
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickDetailView.swift

@@ -0,0 +1,92 @@
+import SwiftUI
+
+struct ContactTrickDetailView: View {
+    @Environment(\.dismiss) var dismiss
+    @ObservedObject var state: ContactTrick.StateModel
+
+    @State private var contactTrickEntry: ContactTrickEntry
+
+    init(entry: ContactTrickEntry, state: ContactTrick.StateModel) {
+        self.state = state
+        _contactTrickEntry = State(initialValue: entry)
+    }
+
+    var body: some View {
+        Form {
+            Section {
+                HStack {
+                    // TODO: - make this beautiful @Dan
+                    Spacer()
+                    Image(uiImage: ContactPicture.getImage(contact: contactTrickEntry, state: state.previewState))
+                        .resizable()
+                        .frame(width: 100, height: 100)
+                        .clipShape(Circle())
+                        .overlay(Circle().stroke(Color.gray, lineWidth: 1))
+                    Spacer()
+                }
+            }
+
+            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)
+                    }
+                }
+                .pickerStyle(SegmentedPickerStyle())
+            }
+
+            Section(header: Text("Primary Value")) {
+                Picker("Primary", selection: $contactTrickEntry.primary) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
+                    }
+                }
+            }
+
+            Section(header: Text("Additional Values")) {
+                Picker("Top Value", selection: $contactTrickEntry.top) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
+                    }
+                }
+
+                Picker("Bottom Value", selection: $contactTrickEntry.bottom) {
+                    ForEach(ContactTrickValue.allCases, id: \.id) { value in
+                        Text(value.displayName).tag(value)
+                    }
+                }
+            }
+
+            Section(header: Text("Ring Settings")) {
+                Picker("Ring Width", selection: $contactTrickEntry.ringWidth) {
+                    ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
+                        Text(width.displayName)
+                            .tag(width)
+                    }
+                }
+
+                Picker("Ring Gap", selection: $contactTrickEntry.ringGap) {
+                    ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
+                        Text(gap.displayName)
+                            .tag(gap)
+                    }
+                }
+            }
+        }
+        .navigationBarTitle("Edit Contact Trick", displayMode: .inline)
+        .navigationBarItems(
+            trailing: Button("Save") {
+                saveChanges()
+            }
+        )
+    }
+
+    private func saveChanges() {
+        Task {
+            await state.updateContact(with: contactTrickEntry)
+            dismiss()
+        }
+    }
+}

+ 42 - 382
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift

@@ -6,404 +6,64 @@ 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)
-                        }
-                    }
-
-                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")
+            NavigationView {
+                contactTrickList
+                    .navigationTitle("Contact Tricks")
+                    .onAppear(perform: configureView)
+                    .navigationBarItems(
+                        trailing: Button(action: {
+                            isAddSheetPresented.toggle()
+                        }) {
+                            Image(systemName: "plus")
                         }
-                    }
-
-                case .denied:
-                    Section {
-                        Text(
-                            "Access to contacts denied"
-                        )
-                    }
-
-                case .restricted:
-                    Section {
-                        Text(
-                            "Access to contacts is restricted (parental control?)"
-                        )
-                    }
-
-                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"
-                        )
-                    }
-                }
-
-                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)
-                }
-            }
-            .dynamicTypeSize(...DynamicTypeSize.xxLarge)
-            .onAppear(perform: configureView)
-            .navigationTitle("Contact Image")
-            .navigationBarTitleDisplayMode(.automatic)
-            .navigationBarItems(
-                trailing: EditButton()
-            )
-        }
-
-        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)
-        }
-
-        var previewState: ContactTrickState {
-            let units = state.units
-
-            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
-            )
-        }
-
-        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)
-                    }
-                    .moveDisabled(true)
-                }
-                .onDelete(perform: onDelete)
-            }
-        }
-
-        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)
-                }
-            }
-        }
-
-        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)
+                    .sheet(isPresented: $isAddSheetPresented) {
+                        AddContactTrickSheet(state: state)
                     }
-                }
-                .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)
-                }
             }
-            .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)
-                    }
-                }
-
-                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)
-                                }
+        private var contactTrickList: some View {
+            List {
+                ForEach(state.contactTrickEntries, id: \.id) { entry in
+                    NavigationLink(destination: ContactTrickDetailView(entry: entry, state: state)) {
+                        HStack {
+                            // TODO: - make this beautiful @Dan
+                            ZStack {
+                                Circle()
+                                    .fill(entry.darkMode ? .black : .white)
+                                    .foregroundColor(.white)
+                                    .frame(width: 40, height: 40)
+
+                                Image(uiImage: ContactPicture.getImage(contact: entry, state: state.previewState))
+                                    .resizable()
+                                    .frame(width: 40, height: 40)
+                                    .clipShape(Circle())
+
+                                Circle()
+                                    .stroke(lineWidth: 2)
+                                    .foregroundColor(.white)
+                                    .frame(width: 40, height: 40)
                             }
-                        }
-                    }
 
-                    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)
-                            }
+                            // Entry name
+                            Text("\(entry.name)")
                         }
-                        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)
                     }
                 }
+                .onDelete(perform: onDelete)
             }
         }
 
-        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)
-                        }
-                    }
+        private func onDelete(offsets: IndexSet) {
+            Task {
+                for offset in offsets {
+                    let entry = state.contactTrickEntries[offset]
+                    await state.deleteContact(entry: entry)
                 }
             }
         }

+ 4 - 6
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,13 +101,11 @@ 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)
         }

+ 254 - 315
FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift

@@ -1,45 +1,34 @@
-import Algorithms
 import Combine
 import Contacts
 import CoreData
-import Foundation
 import Swinject
 
 protocol ContactTrickManager {
-    func updateContacts(contacts: [ContactTrickEntry]) async -> Bool
-    var currentContacts: [ContactTrickEntry] { get }
+    func requestAccess() async -> Bool
+    func createContact(name: String) async -> String?
+    func deleteContact(withIdentifier identifier: String) async -> Bool
+    func updateContact(withIdentifier identifier: String, newName: String) async -> Bool
+    @MainActor func updateContactTrickState() async
+    func setImageForContact(contactId: String) async
 }
 
 final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
-    private var state = ContactTrickState()
-    private let iOSContactStore = 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!
+    @Injected() private var contactTrickStorage: ContactTrickStorage!
+    @Injected() private var settingsManager: SettingsManager!
 
-    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 let contactStore = CNContactStore()
 
-    private var eventualFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 1
-        return formatter
-    }
+    // Make it read-only from outside the class
+    private(set) var state = ContactTrickState()
+
+    private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
+    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObject>, Never>?
+    private var subscriptions = Set<AnyCancellable>()
+
+    private var units: GlucoseUnits = .mgdL
 
     private var deltaFormatter: NumberFormatter {
         let formatter = NumberFormatter()
@@ -50,99 +39,69 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
         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()
-
+        units = settingsManager.settings.units
         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()
+                    await self.updateContactTrickState()
+                    await self.updateContactImages()
                 }
             }
             .store(in: &subscriptions)
+
+        registerHandlers()
     }
 
-    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)
+    // MARK: - Core Data observation
 
+    private func registerHandlers() {
+        /*
+         TODO: - Do we really need to update in both cases, i.e. when OrefDetermination entity AND GlucoseStored entity have received updates ?
+         The main use case is showing glucose values and both updates happen ~ at the same time and if a new glucose value arrives the latest Determination gets fetched with that as well. Moreover, we don't need to update on Determination updates at all if the user hasn't chosen to display anything Determination related
+         */
+//
+//        coreDataPublisher?.filterByEntityName("OrefDetermination").sink { [weak self] _ in
+//            guard let self = self else { return }
+//            Task {
+//                await self.updateContactTrickState()
+//                await self.updateContactImages()
+//            }
+//        }.store(in: &subscriptions)
+
+        // Only needed for manual glucose entries
         coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
             guard let self = self else { return }
             Task {
-                await self.configureContactTrickState()
+                await self.updateContactTrickState()
+                await self.updateContactImages()
             }
         }.store(in: &subscriptions)
     }
 
-    private var knownIds: [String] = []
-    private var contacts: [ContactTrickEntry] = []
-
-    var currentContacts: [ContactTrickEntry] {
-        contacts
-    }
+    // MARK: - Core Data Fetches
 
     private func fetchlastDetermination() async -> [NSManagedObjectID] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OrefDetermination.self,
-            onContext: context,
+            onContext: backgroundContext,
             predicate: NSPredicate.enactedDetermination,
             key: "timestamp",
             ascending: false,
             fetchLimit: 1
         )
 
-        return await context.perform {
+        return await backgroundContext.perform {
             guard let fetchedResults = results as? [OrefDetermination] else { return [] }
 
             return fetchedResults.map(\.objectID)
@@ -152,15 +111,14 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
     private func fetchGlucose() async -> [NSManagedObjectID] {
         let results = await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateFor120MinAgo,
+            onContext: backgroundContext,
+            predicate: NSPredicate.predicateFor20MinAgo,
             key: "date",
             ascending: false,
-            fetchLimit: 24,
-            batchSize: 12
+            fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
         )
 
-        return await context.perform {
+        return await backgroundContext.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 return []
             }
@@ -169,282 +127,263 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable {
         }
     }
 
-    @MainActor private func configureContactTrickState() async {
+    // MARK: - Configure ContactTrickState in order to update ContactTrickImage
+
+    /// Updates the `ContactTrickState` with the latest data from Core Data.
+    /// This function fetches glucose values and determination entries, processes the data,
+    /// and updates the `state` object, which represents the current contact trick state.
+    /// - Important: This function must be called on the main actor to ensure thread safety. Otherwise, we would need to ensure thread safety by either using an actor or a perform closure
+    @MainActor func updateContactTrickState() async {
+        // Get NSManagedObjectIDs on backgroundContext
         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
+        let determinationIds = await fetchlastDetermination()
+
+        // Get NSManagedObjects on MainActor
+        let glucoseObjects: [GlucoseStored] = await CoreDataStack.shared
+            .getNSManagedObject(with: glucoseValuesIds, context: viewContext)
+        let determinationObjects: [OrefDetermination] = await CoreDataStack.shared
+            .getNSManagedObject(with: determinationIds, context: viewContext)
+        let lastDetermination = determinationObjects.last
+
+        if let firstGlucoseValue = glucoseObjects.first {
+            let value = settingsManager.settings.units == .mgdL
+                ? Decimal(firstGlucoseValue.glucose)
+                : Decimal(firstGlucoseValue.glucose).asMmolL
+
+            state.glucose = Formatter.glucoseFormatter(for: units).string(from: value as NSNumber)
+            state.trend = firstGlucoseValue.directionEnum?.symbol
+
+            let delta = glucoseObjects.count >= 2
+                ? Decimal(firstGlucoseValue.glucose) - Decimal(glucoseObjects.dropFirst().first?.glucose ?? 0)
+                : 0
+            let deltaConverted = settingsManager.settings.units == .mgdL ? delta : delta.asMmolL
+            state.delta = deltaFormatter.string(from: deltaConverted as NSNumber)
         }
 
-        do {
-            let glucoseValues: [GlucoseStored] = await CoreDataStack.shared
-                .getNSManagedObject(with: glucoseValuesIds, context: viewContext)
-            let lastDetermination = try viewContext.existingObject(with: lastDeterminationId) as? OrefDetermination
+        state.lastLoopDate = lastDetermination?.timestamp
 
-            await MainActor.run { [weak self] in
-                guard let self = self else { return }
+        state.iob = lastDetermination?.iob as? Decimal
+        if let cobValue = lastDetermination?.cob {
+            state.cob = Decimal(cobValue)
+        } else {
+            state.cob = 0
+        }
 
-                if let firstGlucoseValue = glucoseValues.first {
-                    let value = self.settingsManager.settings.units == .mgdL
-                        ? Decimal(firstGlucoseValue.glucose)
-                        : Decimal(firstGlucoseValue.glucose).asMmolL
+        if let eventualBG = settingsManager.settings.units == .mgdL ? lastDetermination?
+            .eventualBG : lastDetermination?
+            .eventualBG?.decimalValue.asMmolL as NSDecimalNumber?
+        {
+            let eventualBGAsString = Formatter.decimalFormatterWithOneFractionDigit.string(from: eventualBG)
+            state.eventualBG = eventualBGAsString.map { "⇢ " + $0 }
+        }
+    }
 
-                    self.state.glucose = self.glucoseFormatter.string(from: value as NSNumber)
-                    self.state.trend = firstGlucoseValue.directionEnum?.symbol
+    // MARK: - Interactions with CNContactStore API
 
-                    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)
-                }
+    /// 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)
+            }
+        }
+    }
 
-                self.state.lastLoopDate = lastDetermination?.timestamp
+    /// Sets the image for a specific contact in Apple Contacts.
+    /// This function fetches the associated `ContactTrickEntry` for the provided contact ID, generates an image
+    /// based on the current `ContactTrickState`, and updates the contact in the user's Apple Contacts.
+    /// - Parameter contactId: The unique identifier of the contact in Apple Contacts.
+    /// - Important: This function should be called when a new contact is created and needs its initial image set.
+    func setImageForContact(contactId: String) async {
+        guard let contactEntry = await contactTrickStorage.fetchContactTrickEntries().first(where: { $0.contactId == contactId })
+        else {
+            debugPrint("\(DebuggingIdentifiers.failed) No matching ContactTrickEntry found for contact ID: \(contactId)")
+            return
+        }
 
-                self.state.iob = lastDetermination?.iob as? Decimal
-                if let cobValue = lastDetermination?.cob {
-                    self.state.cob = Decimal(cobValue)
-                } else {
-                    self.state.cob = 0
-                }
+        // Create image based on current state
+        let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
 
-                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 }
-                }
+        do {
 
-//                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
-                }
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactImageDataKey as CNKeyDescriptor
+                ]
+            )
+
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(contactId) not found.")
+                return
             }
 
-        } catch let error as NSError {
-            debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to configure state with error: \(error)")
-        }
-    }
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.imageData = newImage.pngData()
 
-    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)
+            let saveRequest = CNSaveRequest()
+            saveRequest.update(mutableContact)
 
-        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)
-                    }
-                }
+            try contactStore.execute(saveRequest)
 
-                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
-            }
+            debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)")
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)")
         }
     }
 
-    private let keysToFetch = [
-        CNContactImageDataKey,
-        CNContactGivenNameKey,
-        CNContactOrganizationNameKey
-    ] as [CNKeyDescriptor]
+    /// Updates the images of all contacts stored in Core Data.
+    /// This function iterates through all stored `ContactTrickEntry` objects, generates a new contact image
+    /// based on the current `ContactTrickState`, and updates the image in the user's Apple Contacts.
+    /// - Important: This function should be called whenever the `ContactTrickState` changes.
+    func updateContactImages() async {
+        // Iterate through all stored ContactTrickEntry objects
+        for contactEntry in await contactTrickStorage.fetchContactTrickEntries() {
+            // Ensure the contact has a valid contact ID
+            guard let contactId = contactEntry.contactId else { continue }
 
-    private func renderContact(_ _entry: ContactTrickEntry, _ index: Int, _ state: ContactTrickState) -> ContactTrickEntry {
-        var entry = _entry
-        let mutableContact: CNMutableContact
-        let saveRequest = CNSaveRequest()
+            // Generate a new image for the contact based on the updated state
+            let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
 
-        if let contactId = entry.contactId {
             do {
-                let contact = try iOSContactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch)
+                // Fetch the existing contact from CNContactStore using its identifier
+                let predicate = CNContact.predicateForContacts(withIdentifiers: [contactId])
+                let contacts = try contactStore.unifiedContacts(
+                    matching: predicate,
+                    keysToFetch: [
+                        CNContactIdentifierKey as CNKeyDescriptor, // To identify the contact
+                        CNContactImageDataKey as CNKeyDescriptor // To fetch current image data
+                    ]
+                )
 
-                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
+                // Ensure the contact exists in the CNContactStore
+                guard let contact = contacts.first else {
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) Contact with ID \(contactId) and name \(contactEntry.name) not found."
                     )
-                } else {
-                    print("in handleEnabledContact, failed to fetch the contact - \(getContactsErrorDetails(error))")
-                    return entry
+                    continue
                 }
+
+                // Create a mutable copy of the contact to update its image
+                let mutableContact = contact.mutableCopy() as! CNMutableContact
+                mutableContact.imageData = newImage.pngData() // Set the new image data
+
+                // Prepare a save request to update the contact
+                let saveRequest = CNSaveRequest()
+                saveRequest.update(mutableContact)
+
+                // Execute the save request to persist the changes
+                try contactStore.execute(saveRequest)
+
+                debugPrint("\(DebuggingIdentifiers.succeeded) Updated contact image for \(contactId)")
             } catch {
-                print("in handleEnabledContact, failed to fetch the contact: \(error.localizedDescription)")
-                return entry
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)")
             }
-        } else {
-            print("no contact \(index) - creating")
-            mutableContact = createNewContact(
-                entry: entry,
-                index: index,
-                state: state,
-                saveRequest: saveRequest
-            )
         }
+    }
 
-        executeSaveRequest(saveRequest)
+    /// 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
 
-        entry.contactId = mutableContact.identifier
+            let saveRequest = CNSaveRequest()
+            saveRequest.add(contact, toContainerWithIdentifier: nil)
 
-        return entry
-    }
+            try contactStore.execute(saveRequest)
 
-    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
-    }
+            // 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]
+            )
+
+            guard let createdContact = contacts.first else {
+                debugPrint("Contact creation failed: No contact found after save.")
+                return nil
+            }
 
-    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()
+            return createdContact.identifier
+        } catch {
+            print("Error creating contact: \(error)")
+            return nil
+        }
     }
 
-    private func deleteContact(_ contactId: String) -> Bool {
+    /// 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 {
-            print("deleting contact \(contactId)")
-            let keysToFetch = [CNContactIdentifierKey as CNKeyDescriptor] // we don't really need any, so just ID
-            let contact = try iOSContactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch)
+            // 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 mutableContact = contact.mutableCopy() as? CNMutableContact else {
-                print("in deleteContact, failed to get a mutable copy of the contact")
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
                 return false
             }
 
-            let saveRequest = CNSaveRequest()
-            saveRequest.delete(mutableContact)
-            try iOSContactStore.execute(saveRequest)
+            // Contact found -> Delete it.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            let deleteRequest = CNSaveRequest()
+            deleteRequest.delete(mutableContact)
+
+            try contactStore.execute(deleteRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)")
             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)")
+            debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)")
             return false
         }
     }
 
-    private func executeSaveRequest(_ saveRequest: CNSaveRequest) {
+    /// 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 {
-            try iOSContactStore.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)")
-        }
-    }
+            // 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
+                ]
+            )
 
-    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)"
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
+                return false
             }
-        }
-        return "\(details ?? "no details"): \(error.localizedDescription)"
-    }
 
-    private func descriptionForTarget(_ target: TempTarget) -> String {
-        let units = settingsManager.settings.units
+            // Update the contact.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.givenName = newName
 
-        var low = target.targetBottom
-        var high = target.targetTop
-        if units == .mmolL {
-            low = low?.asMmolL
-            high = high?.asMmolL
-        }
+            let updateRequest = CNSaveRequest()
+            updateRequest.update(mutableContact)
 
-        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()
+            try contactStore.execute(updateRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)")
+            return true
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)")
+            return false
         }
     }
 }

FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift → FreeAPS/Sources/Services/ContactTrick/ContactTrickPicture.swift


+ 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>