diff --git a/FreeAPS/Sources/Models/ContactTrickEntry.swift b/FreeAPS/Sources/Models/ContactTrickEntry.swift index c2e16925e2..a2cafb2cd3 100644 --- a/FreeAPS/Sources/Models/ContactTrickEntry.swift +++ b/FreeAPS/Sources/Models/ContactTrickEntry.swift @@ -1,17 +1,16 @@ -struct ContactTrickEntry: JSON, Equatable { - var enabled: Bool = false +struct ContactTrickEntry: JSON, Equatable, Hashable { var layout: ContactTrickLayout = .single var ring1: ContactTrickLargeRing = .none var primary: ContactTrickValue = .glucose var top: ContactTrickValue = .none var bottom: ContactTrickValue = .none var contactId: String? = nil - var displayName: String? = nil var darkMode: Bool = true var ringWidth: Int = 7 var ringGap: Int = 2 - var fontSize: Int = 100 + var fontSize: Int = 300 + var secondaryFontSize: Int = 250 var fontName: String = "Default Font" var fontWeight: FontWeight = .medium var fontTracking: FontTracking = .normal @@ -27,18 +26,17 @@ protocol ContactTrickObserver { extension ContactTrickEntry { private enum CodingKeys: String, CodingKey { - case enabled case layout case ring1 case primary case top case bottom case contactId - case displayName case darkMode case ringWidth case ringGap case fontSize + case secondaryFontSize case fontName case fontWeight case fontTracking @@ -46,35 +44,33 @@ extension ContactTrickEntry { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false let layout = try container.decodeIfPresent(ContactTrickLayout.self, forKey: .layout) ?? .single let ring1 = try container.decodeIfPresent(ContactTrickLargeRing.self, forKey: .ring1) ?? .none let primary = try container.decodeIfPresent(ContactTrickValue.self, forKey: .primary) ?? .glucose let top = try container.decodeIfPresent(ContactTrickValue.self, forKey: .top) ?? .none let bottom = try container.decodeIfPresent(ContactTrickValue.self, forKey: .bottom) ?? .none let contactId = try container.decodeIfPresent(String.self, forKey: .contactId) - let displayName = try container.decodeIfPresent(String.self, forKey: .displayName) let darkMode = try container.decodeIfPresent(Bool.self, forKey: .darkMode) ?? true let ringWidth = try container.decodeIfPresent(Int.self, forKey: .ringWidth) ?? 7 let ringGap = try container.decodeIfPresent(Int.self, forKey: .ringGap) ?? 2 - let fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 100 + let fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 300 + let secondaryFontSize = try container.decodeIfPresent(Int.self, forKey: .secondaryFontSize) ?? 250 let fontName = try container.decodeIfPresent(String.self, forKey: .fontName) ?? "Default Font" let fontWeight = try container.decodeIfPresent(FontWeight.self, forKey: .fontWeight) ?? .regular let fontTracking = try container.decodeIfPresent(FontTracking.self, forKey: .fontTracking) ?? .normal self = ContactTrickEntry( - enabled: enabled, layout: layout, ring1: ring1, primary: primary, top: top, bottom: bottom, contactId: contactId, - displayName: displayName, darkMode: darkMode, ringWidth: ringWidth, ringGap: ringGap, fontSize: fontSize, + secondaryFontSize: secondaryFontSize, fontName: fontName, fontWeight: fontWeight, fontTracking: fontTracking diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift index 4c3cf67fb3..a986506f92 100644 --- a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift @@ -26,5 +26,5 @@ enum ContactTrick { protocol ContactTrickProvider: Provider { var contacts: [ContactTrickEntry] { get } - func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher + func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher<[ContactTrickEntry], Error> } diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift index a9f08f6dde..dbcb255712 100644 --- a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift @@ -11,13 +11,12 @@ extension ContactTrick { ?? [] } - func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher { + func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher<[ContactTrickEntry], Error> { Future { promise in - self.storage.save(contacts, as: OpenAPS.Settings.contactTrick) self.contactTrickManager.updateContacts(contacts: contacts) { result in switch result { - case .success: - promise(.success(())) + case let .success(updated): + promise(.success(updated)) case let .failure(error): promise(.failure(error)) } diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift index 67d6039935..4f4c537403 100644 --- a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift @@ -78,8 +78,9 @@ enum ContactTrickLargeRing: String, JSON, CaseIterable, Identifiable, Codable { extension ContactTrick { final class StateModel: BaseStateModel { - @Published var syncInProgress = false - @Published var items: [Item] = [] + @Published private(set) var syncInProgress = false + @Published private(set) var items: [Item] = [] + @Published private(set) var changed: Bool = false override func subscribe() { items = provider.contacts.enumerated().map { index, contact in @@ -88,24 +89,27 @@ extension ContactTrick { entry: contact ) } + changed = false } func add() { let newItem = Item( index: items.count, - entry: ContactTrickEntry( - enabled: false, - layout: .single, - contactId: nil, - displayName: nil, - darkMode: true, - fontSize: 100, - fontName: "Default Font", - fontWeight: .medium - ) + entry: ContactTrickEntry() ) items.append(newItem) + changed = true + } + + func update(_ atIndex: Int, _ value: ContactTrickEntry) { + items[atIndex].entry = value + changed = true + } + + func remove(atOffsets: IndexSet) { + items.remove(atOffsets: atOffsets) + changed = true } func save() { @@ -116,9 +120,13 @@ extension ContactTrick { provider.saveContacts(contacts) .receive(on: DispatchQueue.main) .sink { _ in - print("saved!") self.syncInProgress = false - } receiveValue: {} + self.changed = false + } receiveValue: { contacts in + contacts.enumerated().forEach { index, item in + self.items[index].entry = item + } + } .store(in: &lifetime) } } diff --git a/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift b/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift index d8ce5483d5..3b9d32d6ca 100644 --- a/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift +++ b/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift @@ -8,6 +8,24 @@ extension ContactTrick { let resolver: Resolver @StateObject var state = StateModel() + @Environment(\.colorScheme) var colorScheme + private var color: LinearGradient { + colorScheme == .dark ? LinearGradient( + gradient: Gradient(colors: [ + Color.bgDarkBlue, + Color.bgDarkerDarkBlue + ]), + startPoint: .top, + endPoint: .bottom + ) + : + LinearGradient( + gradient: Gradient(colors: [Color.gray.opacity(0.1)]), + startPoint: .top, + endPoint: .bottom + ) + } + @State private var contactStore = CNContactStore() @State private var authorization = CNContactStore.authorizationStatus(for: .contacts) @@ -19,7 +37,12 @@ extension ContactTrick { list addButton } - Section { + 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) @@ -28,45 +51,46 @@ extension ContactTrick { label: { Text(state.syncInProgress ? "Saving..." : "Save") } - .disabled(state.syncInProgress || state.items.isEmpty) + .disabled(state.syncInProgress || !state.changed) + .frame(maxWidth: .infinity, alignment: .center) } } case .notDetermined: Section { Text( - "Need to ask for contacts access" + "iAPS needs access to your contacts for this feature to work" ) } Section { Button(action: onRequestContactsAccess) { - Text("Grant access to contacts") + Text("Grant iAPS access to contacts") } } case .denied: Section { Text( - "Contacts access denied" + "Access to contacts denied" ) } case .restricted: Section { Text( - "Contacts access - restricted (parental control?)" + "Access to contacts is restricted (parental control?)" ) } @unknown default: Section { Text( - "Contacts access - unknown" + "Access to contacts - unknown state" ) } } } - .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .scrollContentBackground(.hidden).background(color) .onAppear(perform: configureView) .navigationTitle("Contact Trick") .navigationBarTitleDisplayMode(.automatic) @@ -76,28 +100,31 @@ extension ContactTrick { } private func contactSettings(for index: Int) -> some View { - EntryView(entry: $state.items[index].entry) + EntryView(entry: Binding( + get: { state.items[index].entry }, + set: { newValue in state.update(index, newValue) } + )) } + static let previewState = ContactTrickState( + glucose: "6,8", + trend: "↗︎", + delta: "0,3", + lastLoopDate: .now, + iob: 6.1, + iobText: "6,1", + cob: 27.0, + cobText: "27", + eventualBG: "8,9", + maxIOB: 12.0, + maxCOB: 120.0 + ) + private var list: some View { List { - ForEach(state.items.indexed(), id: \.1.id) { index, _ in + ForEach(state.items.indexed(), id: \.1.id) { index, item in NavigationLink(destination: contactSettings(for: index)) { - HStack { - Text( - state.items[index].entry.displayName ?? "Contact not selected" - ) - .font(.body) - .minimumScaleFactor(0.5) - .lineLimit(1) - - Spacer() - - Text( - state.items[index].entry.primary.displayName - ) - .foregroundColor(.accentColor) - } + EntryListView(entry: .constant(item.entry), index: .constant(index)) } .moveDisabled(true) } @@ -122,171 +149,238 @@ extension ContactTrick { } private func onDelete(offsets: IndexSet) { - state.items.remove(atOffsets: offsets) + state.remove(atOffsets: offsets) + } + } + + struct EntryListView: View { + @Binding var entry: ContactTrickEntry + @Binding var index: Int + @State private var refreshKey = UUID() + + var body: some View { + HStack { + Text( + "Contact: iAPS \(index + 1)" + ) + .font(.body) + .minimumScaleFactor(0.5) + .lineLimit(1) + + Spacer() + + VStack { + GeometryReader { geometry in + ZStack { + Image(uiImage: ContactPicture.getImage(contact: entry, state: RootView.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) + } + } + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 30) + } + .frame(maxWidth: .infinity) } } struct EntryView: View { @Binding var entry: ContactTrickEntry - @State private var showContactPicker = false @State private var availableFonts: [String]? = nil + @Environment(\.colorScheme) var colorScheme + private var color: LinearGradient { + colorScheme == .dark ? LinearGradient( + gradient: Gradient(colors: [ + Color.bgDarkBlue, + Color.bgDarkerDarkBlue + ]), + startPoint: .top, + endPoint: .bottom + ) + : + LinearGradient( + gradient: Gradient(colors: [Color.gray.opacity(0.1)]), + startPoint: .top, + endPoint: .bottom + ) + } - private let fontSizes: [Int] = [70, 80, 90, 100, 110, 120, 130, 140, 150] + private let fontSizes: [Int] = [100, 120, 130, 140, 160, 180, 200, 225, 250, 275, 300, 350, 400] private let ringWidths: [Int] = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] private let ringGaps: [Int] = [0, 1, 2, 3, 4, 5] var body: some View { - Form { - Section { - if let displayName = entry.displayName { - Text(displayName) - } - Button(entry.contactId == nil ? "Select contact" : "Change contact") { - showContactPicker = true - } - } + VStack { Section { - Toggle("Enabled", isOn: $entry.enabled) - Picker( - selection: $entry.layout, - label: Text("Layout") - ) { - ForEach(ContactTrickLayout.allCases) { v in - Text(v.displayName).tag(v) + HStack { + ZStack { + Image(uiImage: ContactPicture.getImage(contact: entry, state: RootView.previewState)) + .resizable() + .aspectRatio(1, contentMode: .fit) + .frame(width: 64, height: 64) + .clipShape(Circle()) + Circle() + .stroke(lineWidth: 2) + .foregroundColor(.white) } + .frame(width: 64, height: 64) } } - Section { - switch entry.layout { - case .single: + Form { + Section { Picker( - selection: $entry.primary, - label: Text("Primary") + selection: $entry.layout, + label: Text("Layout") ) { - ForEach(ContactTrickValue.allCases) { v in + ForEach(ContactTrickLayout.allCases) { v in Text(v.displayName).tag(v) } } - Picker( - selection: $entry.top, - label: Text("Top") - ) { - ForEach(ContactTrickValue.allCases) { v in - Text(v.displayName).tag(v) + } + Section { + switch entry.layout { + case .single: + Picker( + selection: $entry.primary, + label: Text("Primary") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.top, + label: Text("Top") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.bottom, + label: Text("Bottom") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + case .split: + Picker( + selection: $entry.top, + label: Text("Top") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.bottom, + label: Text("Bottom") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } } } + } + + Section(header: Text("Ring")) { Picker( - selection: $entry.bottom, - label: Text("Bottom") + selection: $entry.ring1, + label: Text("Outer") ) { - ForEach(ContactTrickValue.allCases) { v in + ForEach(ContactTrickLargeRing.allCases) { v in Text(v.displayName).tag(v) } } - case .split: Picker( - selection: $entry.top, - label: Text("Top") + selection: $entry.ringWidth, + label: Text("Width") ) { - ForEach(ContactTrickValue.allCases) { v in - Text(v.displayName).tag(v) + ForEach(ringWidths, id: \.self) { s in + Text("\(s)").tag(s) } } Picker( - selection: $entry.bottom, - label: Text("Bottom") + selection: $entry.ringGap, + label: Text("Gap") ) { - ForEach(ContactTrickValue.allCases) { v in - Text(v.displayName).tag(v) + ForEach(ringGaps, id: \.self) { s in + Text("\(s)").tag(s) } } } - } - Section(header: Text("Ring")) { - Picker( - selection: $entry.ring1, - label: Text("Outer") - ) { - ForEach(ContactTrickLargeRing.allCases) { v in - Text(v.displayName).tag(v) - } - } - Picker( - selection: $entry.ringWidth, - label: Text("Width") - ) { - ForEach(ringWidths, id: \.self) { s in - Text("\(s)").tag(s) - } - } - Picker( - selection: $entry.ringGap, - label: Text("Gap") - ) { - ForEach(ringGaps, id: \.self) { s in - Text("\(s)").tag(s) - } - } - } - - Section(header: Text("Font")) { - if availableFonts == nil { - HStack { - Spacer() - Button { - loadFonts() - } label: { - Text(entry.fontName) + Section(header: Text("Font")) { + if availableFonts == nil { + HStack { + Spacer() + Button { + loadFonts() + } label: { + Text(entry.fontName) + } } + } else { + Picker( + selection: $entry.fontName, + label: EmptyView() + ) { + ForEach(availableFonts!, id: \.self) { f in + Text(f).tag(f) + } + } + .pickerStyle(.navigationLink) + .labelsHidden() } - } else { Picker( - selection: $entry.fontName, - label: EmptyView() + selection: $entry.fontSize, + label: Text("Size") ) { - ForEach(availableFonts!, id: \.self) { f in - Text(f).tag(f) + ForEach(fontSizes, id: \.self) { s in + Text("\(s)").tag(s) } } - .pickerStyle(.navigationLink) - .labelsHidden() - } - Picker( - selection: $entry.fontSize, - label: Text("Size") - ) { - ForEach(fontSizes, id: \.self) { s in - Text("\(s)").tag(s) - } - } - Picker( - selection: $entry.fontTracking, - label: Text("Tracking") - ) { - ForEach(FontTracking.allCases) { w in - Text(w.displayName).tag(w) + Picker( + selection: $entry.secondaryFontSize, + label: Text("Secondary size") + ) { + ForEach(fontSizes, id: \.self) { s in + Text("\(s)").tag(s) + } } - } - if entry.isDefaultFont() { Picker( - selection: $entry.fontWeight, - label: Text("Weight") + selection: $entry.fontTracking, + label: Text("Tracking") ) { - ForEach(FontWeight.allCases) { w in + ForEach(FontTracking.allCases) { w in Text(w.displayName).tag(w) } } + if entry.isDefaultFont() { + Picker( + selection: $entry.fontWeight, + label: Text("Weight") + ) { + ForEach(FontWeight.allCases) { w in + Text(w.displayName).tag(w) + } + } + } + } + Section { + Toggle("Dark mode", isOn: $entry.darkMode) } } - Section { - Toggle("Dark mode", isOn: $entry.darkMode) - } - } -// .navigationTitle(entry.displayName ?? "Contact not selected") - .fullScreenCover(isPresented: $showContactPicker) { - ContactPicker(entry: $entry) } + .scrollContentBackground(.hidden).background(color) } private func loadFonts() { @@ -304,42 +398,4 @@ extension ContactTrick { availableFonts = data } } - - struct ContactPicker: UIViewControllerRepresentable { - @Binding var entry: ContactTrickEntry - - func makeUIViewController(context: Context) -> CNContactPickerViewController { - let picker = CNContactPickerViewController() - picker.delegate = context.coordinator - return picker - } - - func updateUIViewController(_: CNContactPickerViewController, context _: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, CNContactPickerDelegate { - var parent: ContactPicker - - init(_ parent: ContactPicker) { - self.parent = parent - } - - func contactPicker(_: CNContactPickerViewController, didSelect contact: CNContact) { - parent.entry.contactId = contact.identifier - let display = if let emailAddress = contact.emailAddresses.first { - "\(emailAddress.value)" - } else { - "\(contact.familyName) \(contact.givenName))" - } - if display.isEmpty { - parent.entry.displayName = "Unnamed contact" - } else { - parent.entry.displayName = display - } - } - } - } } diff --git a/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift b/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift index 1f988fa5b2..b826598722 100644 --- a/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift +++ b/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift @@ -15,19 +15,12 @@ struct ContactPicture: View { return formatter }() - private static let numberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.decimalSeparator = "." - return formatter - }() - static func getImage( contact: ContactTrickEntry, state: ContactTrickState ) -> UIImage { - let width = 256.0 - let height = 256.0 + 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) : @@ -38,35 +31,32 @@ struct ContactPicture: View { let fontWeight = contact.fontWeight.toUI() UIGraphicsBeginImageContext(rect.size) + if let context = UIGraphicsGetCurrentContext() { + context.setShouldAntialias(true) + context.setAllowsAntialiasing(true) + } let ringWidth = Double(contact.ringWidth) / 100.0 let ringGap = Double(contact.ringGap) / 100.0 - - rect = CGRect( - x: rect.minX + width * ringGap, - y: rect.minY + height * ringGap, - width: rect.width - width * ringGap * 2, - height: rect.height - height * ringGap * 2 - ) + let outerGap = 0.03 if contact.ring1 != .none { rect = CGRect( - x: rect.minX + width * ringGap, - y: rect.minY + height * ringGap, - width: rect.width - width * ringGap * 2, - height: rect.height - height * ringGap * 2 + 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 * ringGap, - y: rect.minY + height * ringGap, - width: rect.width - width * ringGap * 2, - height: rect.height - width * ringGap * 2 + 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.ring1, contact: contact, state: state, rect: ringRect, strokeWidth: width * ringWidth) - } - if contact.ring1 != .none { rect = CGRect( x: rect.minX + width * (ringWidth + ringGap), y: rect.minY + height * (ringWidth + ringGap), @@ -84,7 +74,7 @@ struct ContactPicture: View { let centerY = rect.minY + rect.height / 2 let radius = min(rect.width, rect.height) / 2 - let primaryHeight = radius * 0.8 + var primaryHeight = radius * 0.8 let topHeight = radius * 0.5 var bottomHeight = radius * 0.5 @@ -98,41 +88,48 @@ struct ContactPicture: View { } let topY = primaryY - topHeight - let bottomY = primaryY + primaryHeight - - let primaryWidth = 2 * sqrt(radius * radius - (primaryHeight / 2) * (primaryHeight / 2)) + var bottomY = primaryY + primaryHeight + let primaryWidth = 2 * sqrt(radius * radius - (primaryHeight * 0.5) * (primaryHeight * 0.5)) let topWidth = 2 * - sqrt(radius * radius - (topHeight + primaryHeight / 2) * (topHeight + primaryHeight / 2)) + sqrt(radius * radius - (topHeight + primaryHeight * 0.5) * (topHeight + primaryHeight * 0.5)) var bottomWidth = 2 * - sqrt(radius * radius - (bottomHeight + primaryHeight / 2) * (bottomHeight + primaryHeight / 2)) + sqrt(radius * radius - (bottomHeight + primaryHeight * 0.5) * (bottomHeight + primaryHeight * 0.5)) - if contact.bottom != .none, contact.top == .none, - contact.ring1 == .iob || contact.ring1 == .cob || contact.ring1 == .iobcob - { - bottomWidth = bottomWidth + width * ringWidth * 2 - bottomHeight = bottomHeight + height * ringWidth * 2 + if contact.bottom != .none, contact.top == .none { + // move things around a little bit to give more space to the bottom area + if contact.ring1 == .iob || contact.ring1 == .cob || contact.ring1 == .iobcob || + (contact.bottom == .trend && contact.ring1 == .loop) + { + bottomHeight = bottomHeight + height * ringWidth * 2 + bottomWidth = bottomWidth + width * ringWidth * 2 + } else if contact.ring1 == .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 / 2, + x: centerX - primaryWidth * 0.5, y: primaryY, width: primaryWidth, height: primaryHeight ) : rect let topRect = CGRect( - x: centerX - topWidth / 2, + x: centerX - topWidth * 0.5, y: topY, width: topWidth, height: topHeight ) let bottomRect = CGRect( - x: centerX - bottomWidth / 2, + x: centerX - bottomWidth * 0.5, y: bottomY, width: bottomWidth, height: bottomHeight ) - let secondaryFontSize = Int(Double(contact.fontSize) * 0.90) + let secondaryFontSize = contact.secondaryFontSize displayPiece( value: contact.primary, @@ -198,7 +195,8 @@ struct ContactPicture: View { width: rectangleWidth, height: rectangleHeight ) - let splitFontSize = Int(Double(contact.fontSize) * 0.80) + let topFontSize = contact.fontSize + let bottomFontSize = contact.secondaryFontSize displayPiece( value: contact.top, @@ -207,7 +205,7 @@ struct ContactPicture: View { rect: topRect, fitHeigh: true, fontName: contact.fontName, - fontSize: splitFontSize, + fontSize: topFontSize, fontWeight: fontWeight, fontTracking: contact.fontTracking, color: textColor @@ -219,7 +217,7 @@ struct ContactPicture: View { rect: bottomRect, fitHeigh: true, fontName: contact.fontName, - fontSize: splitFontSize, + fontSize: bottomFontSize, fontWeight: fontWeight, fontTracking: contact.fontTracking, color: textColor @@ -291,6 +289,11 @@ struct ContactPicture: View { default: nil } + let textColor: Color = switch value { + case .cob: .loopYellow + default: color + } + if let text = text { drawText( text: text, @@ -300,7 +303,7 @@ struct ContactPicture: View { fontSize: fontSize, fontWeight: fontWeight, fontTracking: fontTracking, - color: color + color: textColor ) } } @@ -375,32 +378,34 @@ struct ContactPicture: View { context.strokePath() case .iob: - if let iob = state.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], + colors: [.insulin, Color(red: 0.7215686275, green: 0.3411764706, blue: 1)], strokeWidth: strokeWidth ) } case .cob: - if let cob = state.cob { + if let cob = state.cob, state.maxCOB > 0.01 { drawProgressBar( rect: rect, progress: Double(cob) / Double(state.maxCOB), - colors: [contact.darkMode ? .green : .green, contact.darkMode ? .pink : .red], + colors: [.loopYellow, .red], strokeWidth: strokeWidth ) } case .iobcob: - 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: [contact.darkMode ? .green : .green, contact.darkMode ? .pink : .red], - strokeWidth: strokeWidth - ) + 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: [.insulin, Color(red: 0.7215686275, green: 0.3411764706, blue: 1)], + colors2: [.loopYellow, .red], + strokeWidth: strokeWidth + ) + } default: break } @@ -672,7 +677,6 @@ struct ContactPicture_Previews: PreviewProvider { cob: 25, cobText: "25" )) - ).previewDisplayName("bg + trend + delta") ContactPicturePreview( @@ -728,7 +732,6 @@ struct ContactPicture_Previews: PreviewProvider { trend: "→", lastLoopDate: .now )) - ).previewDisplayName("bg + trend + ring1") ContactPicturePreview( @@ -747,7 +750,6 @@ struct ContactPicture_Previews: PreviewProvider { lastLoopDate: .now - 7.minutes, eventualBG: "6.2" )) - ).previewDisplayName("bg + eventual + ring1") ContactPicturePreview( @@ -766,7 +768,6 @@ struct ContactPicture_Previews: PreviewProvider { trend: "↗︎", lastLoopDate: .now - 2.minutes )) - ).previewDisplayName("lastLoopDate + ring1") ContactPicturePreview( @@ -787,7 +788,6 @@ struct ContactPicture_Previews: PreviewProvider { iobText: "6.1", maxIOB: 8.0 )) - ).previewDisplayName("bg + ring1 + ring2") ContactPicturePreview( @@ -806,7 +806,6 @@ struct ContactPicture_Previews: PreviewProvider { cob: 25, cobText: "25" )) - ).previewDisplayName("iob + cob") ContactPicturePreview( @@ -829,7 +828,6 @@ struct ContactPicture_Previews: PreviewProvider { maxIOB: 10, maxCOB: 120 )) - ).previewDisplayName("iobcob ring") ContactPicturePreview( @@ -850,7 +848,6 @@ struct ContactPicture_Previews: PreviewProvider { maxIOB: 10, maxCOB: 120 )) - ).previewDisplayName("iobcob ring (0/0)") ContactPicturePreview( @@ -871,7 +868,6 @@ struct ContactPicture_Previews: PreviewProvider { maxIOB: 10, maxCOB: 120 )) - ).previewDisplayName("iobcob ring (max/max)") ContactPicturePreview( @@ -895,7 +891,6 @@ struct ContactPicture_Previews: PreviewProvider { maxIOB: 10, maxCOB: 120 )) - ).previewDisplayName("bg + trend + iobcob ring") } } diff --git a/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift b/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift index c424bd3a11..8c55bdfd50 100644 --- a/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift +++ b/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift @@ -5,7 +5,7 @@ import Foundation import Swinject protocol ContactTrickManager { - func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result) -> Void) + func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result<[ContactTrickEntry], Error>) -> Void) } final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable { @@ -17,6 +17,7 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable { @Injected() private var settingsManager: SettingsManager! @Injected() private var storage: FileStorage! + private var knownIds: [String] = [] private var contacts: [ContactTrickEntry] = [] private let coreDataStorage = CoreDataStorage() @@ -32,17 +33,30 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable { ?? [ContactTrickEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.contactTrick)) ?? [] + knownIds = contacts.compactMap(\.contactId) + processQueue.async { self.renderContacts() } } - func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result) -> Void) { + func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result<[ContactTrickEntry], Error>) -> Void) { self.contacts = contacts + let newIds = contacts.compactMap(\.contactId) + + let knownSet = Set(knownIds) + let newSet = Set(newIds) + let removedIds = knownSet.subtracting(newSet) processQueue.async { + removedIds.forEach { contactId in + if !self.deleteContact(contactId) { + print("contacts cleanup, failed to delete contact \(contactId)") + } + } self.renderContacts() - completion(.success(())) + self.knownIds = self.contacts.compactMap(\.contactId) + completion(.success(self.contacts)) } } @@ -74,70 +88,159 @@ final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable { maxCOB: settingsManager.preferences.maxCOB ) - contacts.forEach { renderContact($0, state) } + contacts = contacts.enumerated().map { index, entry in renderContact(entry, index + 1, state) } + + storage.save(contacts, as: OpenAPS.Settings.contactTrick) workItem = DispatchWorkItem(block: { - print("in updateContact, no updates received for more than 5 minutes") + print("in renderContacts, no updates received for more than 5 minutes") self.renderContacts() }) DispatchQueue.main.asyncAfter(deadline: .now() + 5 * 60 + 15, execute: workItem!) } - private func renderContact(_ entry: ContactTrickEntry, _ state: ContactTrickState) { - guard let contactId = entry.contactId, entry.enabled else { - return - } + private let keysToFetch = [ + CNContactImageDataKey, + CNContactGivenNameKey, + CNContactOrganizationNameKey + ] as [CNKeyDescriptor] - let keysToFetch = [CNContactImageDataKey] as [CNKeyDescriptor] + private func renderContact(_ _entry: ContactTrickEntry, _ index: Int, _ state: ContactTrickState) -> ContactTrickEntry { + var entry = _entry + let mutableContact: CNMutableContact + let saveRequest = CNSaveRequest() - let contact: CNContact - do { - contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch) - } catch { - print("in updateContact, an error has been thrown while fetching the selected contact") - return - } + if let contactId = entry.contactId { + do { + let contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch) + + mutableContact = contact.mutableCopy() as! CNMutableContact + updateContactFields(entry: entry, index: index, state: state, mutableContact: mutableContact) + saveRequest.update(mutableContact) + } catch let error as NSError { + if error.code == 200 { // 200: Updated Record Does Not Exist + print("in handleEnabledContact, failed to fetch the contact, code 200, contact does not exist") + mutableContact = createNewContact( + entry: entry, + index: index, + state: state, + saveRequest: saveRequest + ) + } else { + print("in handleEnabledContact, failed to fetch the contact - \(getContactsErrorDetails(error))") + return entry + } + } catch { + print("in handleEnabledContact, failed to fetch the contact: \(error.localizedDescription)") + return entry + } - guard let mutableContact = contact.mutableCopy() as? CNMutableContact else { - return + } else { + print("no contact \(index) - creating") + mutableContact = createNewContact( + entry: entry, + index: index, + state: state, + saveRequest: saveRequest + ) } + saveUpdatedContact(saveRequest) + + entry.contactId = mutableContact.identifier + + return entry + } + + private func createNewContact( + entry: ContactTrickEntry, + index: Int, + state: ContactTrickState, + saveRequest: CNSaveRequest + ) -> CNMutableContact { + let mutableContact = CNMutableContact() + updateContactFields( + entry: entry, index: index, state: state, mutableContact: mutableContact + ) + print("creating a new contact, \(mutableContact.identifier)") + saveRequest.add(mutableContact, toContainerWithIdentifier: nil) + return mutableContact + } + + private func updateContactFields( + entry: ContactTrickEntry, + index: Int, + state: ContactTrickState, + mutableContact: CNMutableContact + ) { + mutableContact.givenName = "iAPS \(index)" + mutableContact + .organizationName = + "Created and managed by iAPS - \(Date().formatted(date: .abbreviated, time: .shortened))" + mutableContact.imageData = ContactPicture.getImage( contact: entry, state: state ).pngData() - - saveUpdatedContact(mutableContact) } - private func saveUpdatedContact(_ mutableContact: CNMutableContact) { - let saveRequest = CNSaveRequest() - saveRequest.update(mutableContact) + private func deleteContact(_ contactId: String) -> Bool { do { + print("deleting contact \(contactId)") + let keysToFetch = [CNContactIdentifierKey as CNKeyDescriptor] // we don't really need any, so just ID + let contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch) + + guard let mutableContact = contact.mutableCopy() as? CNMutableContact else { + print("in deleteContact, failed to get a mutable copy of the contact") + return false + } + + let saveRequest = CNSaveRequest() + saveRequest.delete(mutableContact) try contactStore.execute(saveRequest) + return true } catch let error as NSError { - 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)" - } + if error.code == 200 { // Updated Record Does Not Exist + return true + } else { + print("in deleteContact, failed to update the contact - \(getContactsErrorDetails(error))") + return false } - print("in updateContact, failed to update the contact - \(details ?? "no details"): \(error.localizedDescription)") + } catch { + print("in deleteContact, failed to update the contact: \(error.localizedDescription)") + return false + } + } + private func saveUpdatedContact(_ saveRequest: CNSaveRequest) { + do { + try contactStore.execute(saveRequest) + } catch let error as NSError { + print("in updateContact, failed to update the contact - \(getContactsErrorDetails(error))") } catch { print("in updateContact, failed to update the contact: \(error.localizedDescription)") } } + private func getContactsErrorDetails(_ error: NSError) -> String { + var details: String? + if error.domain == CNErrorDomain { + switch error.code { + case CNError.authorizationDenied.rawValue: + details = "Authorization denied" + case CNError.communicationError.rawValue: + details = "Communication error" + case CNError.insertedRecordAlreadyExists.rawValue: + details = "Record already exists" + case CNError.dataAccessError.rawValue: + details = "Data access error" + default: + details = "Code \(error.code)" + } + } + return "\(details ?? "no details"): \(error.localizedDescription)" + } + private func glucoseText(_ glucose: [Readings]) -> (glucose: String, trend: String, delta: String) { let glucoseValue = glucose.first?.glucose ?? 0