浏览代码

Add Previews back; Refactoring and fixes; add comments; TO DO: make UI pretty

polscm32 aka Marvout 1 年之前
父节点
当前提交
85d49bc330

+ 20 - 4
FreeAPS.xcodeproj/project.pbxproj

@@ -342,7 +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 /* Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC530FE2D0F6BE300088832 /* Manager.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 */; };
@@ -1038,7 +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 /* Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Manager.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>"; };
@@ -2987,6 +2995,8 @@
 			isa = PBXGroup;
 			children = (
 				E592A3712CEEC038009A472C /* ContactTrickRootView.swift */,
+				BDC531112D1060FA00088832 /* ContactTrickDetailView.swift */,
+				BDC531132D10611D00088832 /* AddContactTrickSheet.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -3005,7 +3015,9 @@
 		E592A37E2CEEC046009A472C /* ContactTrick */ = {
 			isa = PBXGroup;
 			children = (
-				BDC530FE2D0F6BE300088832 /* Manager.swift */,
+				BDC530FE2D0F6BE300088832 /* ContactTrickManager.swift */,
+				BDC531152D10629000088832 /* ContactTrickPicture.swift */,
+				BDC531172D1062F200088832 /* ContactTrickState.swift */,
 			);
 			path = ContactTrick;
 			sourceTree = "<group>";
@@ -3769,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 */,
@@ -3788,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 */,
@@ -3845,7 +3859,8 @@
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
-				BDC530FF2D0F6BE300088832 /* Manager.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 */,
@@ -3862,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 */,

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

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

+ 1 - 1
FreeAPS/Sources/Models/ContactTrickEntry.swift

@@ -39,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

+ 45 - 21
FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift

@@ -5,11 +5,26 @@ import SwiftUI
 extension ContactTrick {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var contactTrickStorage: ContactTrickStorage!
+        @ObservationIgnored @Injected() var contactTrickManager: ContactTrickManager!
         var contactTrickEntries = [ContactTrickEntry]()
-        private let contactManager = ContactManager()
-
         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
@@ -33,15 +48,15 @@ extension ContactTrick {
         ///   - name: The name of the contact.
         func createAndSaveContactTrick(entry: ContactTrickEntry, name: String) async {
             // 1. Check for contact access permissions.
-            let hasAccess = await contactManager.requestAccess()
+            let hasAccess = await contactTrickManager.requestAccess()
             guard hasAccess else {
-                print("No access to contacts.")
+                debugPrint("\(DebuggingIdentifiers.failed) No access to contacts.")
                 return
             }
 
             // 2. Create the contact and retrieve its `identifier`.
-            guard let contactId = await contactManager.createContact(name: name) else {
-                print("Failed to create contact.")
+            guard let contactId = await contactTrickManager.createContact(name: name) else {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to create contact.")
                 return
             }
 
@@ -51,6 +66,10 @@ extension ContactTrick {
 
             // 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)
         }
 
         /// Adds a ContactTrickEntry to Core Data.
@@ -64,16 +83,16 @@ extension ContactTrick {
         /// - Parameter entry: The ContactTrickEntry representing the contact to be deleted.
         func deleteContact(entry: ContactTrickEntry) async {
             guard let contactId = entry.contactId else {
-                print("Contact does not have a valid ID.")
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
                 return
             }
 
             // 1. Attempt to delete the contact from Apple Contacts.
-            let contactDeleted = await contactManager.deleteContact(withIdentifier: contactId)
+            let contactDeleted = await contactTrickManager.deleteContact(withIdentifier: contactId)
             if contactDeleted {
-                print("Contact successfully deleted from Apple Contacts: \(contactId)")
+                debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted from Apple Contacts: \(contactId)")
             } else {
-                print("Failed to delete contact from Apple Contacts. Check if it exists.")
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to delete contact from Apple Contacts. Check if it exists.")
             }
 
             // 2. Delete the entry from Core Data.
@@ -92,24 +111,29 @@ extension ContactTrick {
         /// Updates a contact in Apple Contacts and Core Data.
         /// - Parameters:
         ///   - entry: The ContactTrickEntry to be updated.
-        ///   - newName: The new name to assign to the contact.
-        func updateContact(entry: ContactTrickEntry, newName: String) async {
+        func updateContact(with entry: ContactTrickEntry) async {
             guard let contactId = entry.contactId else {
-                print("Contact does not have a valid ID.")
+                debugPrint("\(DebuggingIdentifiers.failed) Contact does not have a valid ID.")
                 return
             }
 
-            // 1. Update the contact in Apple Contacts.
-            let contactUpdated = await contactManager.updateContact(withIdentifier: contactId, newName: newName)
+            // 1. Update the entry in Core Data.
+            await updateContactTrick(entry)
+
+            // 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 {
-                print("Failed to update contact in Apple Contacts.")
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact.")
                 return
             }
-
-            // 2. Update the entry in Core Data.
-            var updatedEntry = entry
-            updatedEntry.name = newName // Update additional fields if needed.
-            await updateContactTrick(updatedEntry)
+            
+            /// Update state and image
+            await contactTrickManager.updateContactTrickState()
+            await contactTrickManager.setImageForContact(contactId: contactId)
         }
 
         /// Updates a Core Data entry.

+ 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()
+        }
+    }
+}

+ 41 - 194
FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift

@@ -11,26 +11,51 @@ extension ContactTrick {
 
         var body: some View {
             NavigationView {
-                List {
-                    ForEach(state.contactTrickEntries, id: \.id) { entry in
-                        NavigationLink(destination: ContactTrickDetailView(entry: entry, state: state)) {
-                            Text("\(entry.name)")
+                contactTrickList
+                    .navigationTitle("Contact Tricks")
+                    .onAppear(perform: configureView)
+                    .navigationBarItems(
+                        trailing: Button(action: {
+                            isAddSheetPresented.toggle()
+                        }) {
+                            Image(systemName: "plus")
                         }
+                    )
+                    .sheet(isPresented: $isAddSheetPresented) {
+                        AddContactTrickSheet(state: state)
                     }
-                    .onDelete(perform: onDelete)
-                }
-                .navigationTitle("Contact Tricks")
-                .onAppear(perform: configureView)
-                .navigationBarItems(
-                    trailing: Button(action: {
-                        isAddSheetPresented.toggle()
-                    }) {
-                        Image(systemName: "plus")
+            }
+        }
+
+        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)
+                            }
+
+                            // Entry name
+                            Text("\(entry.name)")
+                        }
                     }
-                )
-                .sheet(isPresented: $isAddSheetPresented) {
-                    AddContactTrickSheet(state: state)
                 }
+                .onDelete(perform: onDelete)
             }
         }
 
@@ -44,181 +69,3 @@ extension ContactTrick {
         }
     }
 }
-
-struct AddContactTrickSheet: View {
-    @Environment(\.dismiss) var dismiss
-    var state: ContactTrick.StateModel
-
-    @State private var name: String = ""
-    @State private var isDarkMode: Bool = false
-    @State private var ringWidth: ContactTrickEntry.RingWidth = .regular
-    @State private var ringGap: ContactTrickEntry.RingGap = .small
-    @State private var layout: ContactTrickLayout = .single
-    @State private var primary: ContactTrickValue = .glucose
-    @State private var top: ContactTrickValue = .none
-    @State private var bottom: ContactTrickValue = .none
-
-    var body: some View {
-        NavigationView {
-            Form {
-                TextField("Name", text: $name)
-                Section(header: Text("Layout")) {
-                    Toggle("Dark Mode", isOn: $isDarkMode)
-                    Picker("Layout", selection: $layout) {
-                        ForEach(ContactTrickLayout.allCases, id: \.id) { layout in
-                            Text(layout.displayName).tag(layout)
-                        }
-                    }
-                    .pickerStyle(SegmentedPickerStyle())
-                }
-
-                Section(header: Text("Primary Value")) {
-                    Picker("Primary", selection: $primary) {
-                        ForEach(ContactTrickValue.allCases, id: \.id) { value in
-                            Text(value.displayName).tag(value)
-                        }
-                    }
-                }
-
-                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)
-                        }
-                    }
-                }
-
-                Section(header: Text("Ring Settings")) {
-                    Picker("Ring Width", selection: $ringWidth) {
-                        ForEach(ContactTrickEntry.RingWidth.allCases, id: \.self) { width in
-                            Text(width.displayName).tag(width)
-                        }
-                    }
-
-                    Picker("Ring Gap", selection: $ringGap) {
-                        ForEach(ContactTrickEntry.RingGap.allCases, id: \.self) { gap in
-                            Text(gap.displayName).tag(gap)
-                        }
-                    }
-                }
-            }
-            .navigationBarTitle("Add Contact Trick", displayMode: .inline)
-            .navigationBarItems(
-                leading: Button("Cancel") {
-                    dismiss()
-                },
-                trailing: Button("Save") {
-                    saveNewEntry()
-                }
-            )
-        }
-    }
-
-    private func saveNewEntry() {
-        let newEntry = ContactTrickEntry(
-            id: UUID(),
-            name: name,
-            layout: layout,
-            ring: .none,
-            primary: primary,
-            top: top,
-            bottom: bottom,
-            contactId: nil, // Wird später durch die API gesetzt
-            darkMode: isDarkMode,
-            ringWidth: ringWidth,
-            ringGap: ringGap,
-            fontSize: .regular,
-            secondaryFontSize: .small,
-            fontWeight: .medium,
-            fontWidth: .standard
-        )
-        Task {
-            await state.createAndSaveContactTrick(entry: newEntry, name: name)
-            dismiss()
-        }
-    }
-}
-
-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 {
-            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(entry: contactTrickEntry, newName: contactTrickEntry.name)
-            dismiss()
-        }
-    }
-}

+ 1 - 3
FreeAPS/Sources/Modules/WatchConfig/View/WatchConfigAppleWatchView.swift

@@ -101,13 +101,11 @@ struct WatchConfigAppleWatchView: BaseView {
                 content: {
                     VStack {
                         HStack {
-                            NavigationLink("contact image") {
+                            NavigationLink("Contact image") {
                                 ContactTrick.RootView(resolver: resolver)
                             }.foregroundStyle(Color.accentColor)
                         }
                     }
-
-//                        .padding(.bottom)
                 }
             ).listRowBackground(Color.chart)
         }

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

@@ -0,0 +1,387 @@
+import Combine
+import Contacts
+import CoreData
+import Swinject
+
+protocol ContactTrickManager {
+    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 {
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var contactTrickStorage: ContactTrickStorage!
+    @Injected() private var settingsManager: SettingsManager!
+
+    private let contactStore = CNContactStore()
+
+    // 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()
+        formatter.numberStyle = .decimal
+        formatter.maximumFractionDigits = settingsManager.settings.units == .mmolL ? 1 : 0
+        formatter.positivePrefix = "+"
+        formatter.negativePrefix = "-"
+        return formatter
+    }
+
+    init(resolver: Resolver) {
+        super.init()
+        injectServices(resolver)
+        units = settingsManager.settings.units
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: DispatchQueue.global(qos: .background))
+                .share()
+                .eraseToAnyPublisher()
+
+        glucoseStorage.updatePublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    await self.updateContactTrickState()
+                    await self.updateContactImages()
+                }
+            }
+            .store(in: &subscriptions)
+
+        registerHandlers()
+    }
+
+    // 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.updateContactTrickState()
+                await self.updateContactImages()
+            }
+        }.store(in: &subscriptions)
+    }
+
+    // MARK: - Core Data Fetches
+
+    private func fetchlastDetermination() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.enactedDetermination,
+            key: "timestamp",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        return await backgroundContext.perform {
+            guard let fetchedResults = results as? [OrefDetermination] else { return [] }
+
+            return fetchedResults.map(\.objectID)
+        }
+    }
+
+    private func fetchGlucose() async -> [NSManagedObjectID] {
+        let results = await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.predicateFor20MinAgo,
+            key: "date",
+            ascending: false,
+            fetchLimit: 3 /// We only need 1-3 values, depending on whether the user wants to show delta or not
+        )
+
+        return await backgroundContext.perform {
+            guard let glucoseResults = results as? [GlucoseStored] else {
+                return []
+            }
+
+            return glucoseResults.map(\.objectID)
+        }
+    }
+
+    // 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()
+        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)
+        }
+
+        state.lastLoopDate = lastDetermination?.timestamp
+
+        state.iob = lastDetermination?.iob as? Decimal
+        if let cobValue = lastDetermination?.cob {
+            state.cob = Decimal(cobValue)
+        } else {
+            state.cob = 0
+        }
+
+        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 }
+        }
+    }
+
+    // MARK: - Interactions with CNContactStore API
+
+    /// 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)
+            }
+        }
+    }
+
+    /// 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
+        }
+
+        // Create image based on current state
+        let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
+
+        do {
+            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
+            }
+
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.imageData = newImage.pngData()
+
+            let saveRequest = CNSaveRequest()
+            saveRequest.update(mutableContact)
+
+            try contactStore.execute(saveRequest)
+
+            debugPrint("\(DebuggingIdentifiers.succeeded) Image successfully set for contact ID: \(contactId)")
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Failed to set image for contact ID \(contactId): \(error)")
+        }
+    }
+
+    /// 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 }
+
+            // Generate a new image for the contact based on the updated state
+            let newImage = await ContactPicture.getImage(contact: contactEntry, state: state)
+
+            do {
+                // 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
+                    ]
+                )
+
+                // 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."
+                    )
+                    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 {
+                debugPrint("\(DebuggingIdentifiers.failed) Failed to update contact image for \(contactId): \(error)")
+            }
+        }
+    }
+
+    /// Creates a new contact in the Apple contact list.
+    /// - Parameter name: The name of the contact.
+    /// - Returns: The generated `identifier` of the contact, or `nil` if an error occurs.
+    func createContact(name: String) async -> String? {
+        do {
+            let contact = CNMutableContact()
+            contact.givenName = name
+
+            let saveRequest = CNSaveRequest()
+            saveRequest.add(contact, toContainerWithIdentifier: nil)
+
+            try contactStore.execute(saveRequest)
+
+            // Re-fetch the contact to retrieve its `identifier`.
+            let predicate = CNContact.predicateForContacts(matchingName: name)
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            guard let createdContact = contacts.first else {
+                debugPrint("Contact creation failed: No contact found after save.")
+                return nil
+            }
+
+            return createdContact.identifier
+        } catch {
+            print("Error creating contact: \(error)")
+            return nil
+        }
+    }
+
+    /// Deletes a contact from the Apple contact list using its `identifier`.
+    /// - Parameter identifier: The unique identifier of the contact.
+    /// - Returns: `true` if the contact was successfully deleted, `false` otherwise.
+    func deleteContact(withIdentifier identifier: String) async -> Bool {
+        do {
+            // Attempt to find the contact using its identifier.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
+            )
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Contact found -> Delete it.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            let deleteRequest = CNSaveRequest()
+            deleteRequest.delete(mutableContact)
+
+            try contactStore.execute(deleteRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully deleted: \(identifier)")
+            return true
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error deleting contact: \(error)")
+            return false
+        }
+    }
+
+    /// Updates an existing contact in the Apple contact list.
+    /// - Parameters:
+    ///   - identifier: The unique identifier of the contact.
+    ///   - newName: The new name to assign to the contact.
+    /// - Returns: `true` if the contact was successfully updated, `false` otherwise.
+    func updateContact(withIdentifier identifier: String, newName: String) async -> Bool {
+        do {
+            // Search for the contact using its `identifier`.
+            let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier])
+            let contacts = try contactStore.unifiedContacts(
+                matching: predicate,
+                keysToFetch: [
+                    CNContactIdentifierKey as CNKeyDescriptor,
+                    CNContactGivenNameKey as CNKeyDescriptor,
+                    CNContactFamilyNameKey as CNKeyDescriptor
+                ]
+            )
+
+            guard let contact = contacts.first else {
+                debugPrint("\(DebuggingIdentifiers.failed) Contact with ID \(identifier) not found.")
+                return false
+            }
+
+            // Update the contact.
+            let mutableContact = contact.mutableCopy() as! CNMutableContact
+            mutableContact.givenName = newName
+
+            let updateRequest = CNSaveRequest()
+            updateRequest.update(mutableContact)
+
+            try contactStore.execute(updateRequest)
+            debugPrint("\(DebuggingIdentifiers.succeeded) Contact successfully updated: \(identifier)")
+            return true
+        } catch {
+            debugPrint("\(DebuggingIdentifiers.failed) Error updating contact: \(error)")
+            return false
+        }
+    }
+}

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

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

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

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

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

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