227 lines
8.0 KiB
Swift
227 lines
8.0 KiB
Swift
import Combine
|
|
@preconcurrency import Contacts
|
|
@preconcurrency import ContactsUI
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class ContactsViewModel: ObservableObject {
|
|
@Published private(set) var contacts: [CNContact] = []
|
|
@Published private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
|
|
@Published private(set) var lastError: String? = nil
|
|
@Published private(set) var favoriteContactIdentifiers: Set<String> = []
|
|
@Published private(set) var myCardIdentifier: String? = nil
|
|
@Published private(set) var supportsMetadataDSL = false
|
|
|
|
let store = CNContactStore()
|
|
|
|
func refresh() {
|
|
authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
|
|
if authorizationStatus == .authorized || authorizationStatus == .limited {
|
|
loadContacts()
|
|
} else {
|
|
contacts = []
|
|
lastError = nil
|
|
}
|
|
}
|
|
|
|
func clearError() {
|
|
lastError = nil
|
|
}
|
|
|
|
func isFavorite(_ contact: CNContact) -> Bool {
|
|
favoriteContactIdentifiers.contains(contact.identifier)
|
|
}
|
|
|
|
func isMyCard(_ contact: CNContact) -> Bool {
|
|
myCardIdentifier == contact.identifier
|
|
}
|
|
|
|
var myCard: CNContact? {
|
|
guard let myCardIdentifier else { return nil }
|
|
return contacts.first(where: { $0.identifier == myCardIdentifier })
|
|
}
|
|
|
|
func toggleFavorite(for contact: CNContact) {
|
|
updateMetadata(for: contact) { metadata in
|
|
metadata.isFavorite.toggle()
|
|
}
|
|
}
|
|
|
|
func toggleMyCard(for contact: CNContact) {
|
|
guard supportsMetadataDSL else {
|
|
lastError = String(localized: "contacts.metadata_unavailable")
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Ensure no other contact remains tagged as My Card.
|
|
let containerID = store.defaultContainerIdentifier()
|
|
let keys: [CNKeyDescriptor] = [
|
|
CNContactIdentifierKey as CNKeyDescriptor,
|
|
CNContactNoteKey as CNKeyDescriptor
|
|
]
|
|
let all = try store.unifiedContacts(
|
|
matching: CNContact.predicateForContactsInContainer(withIdentifier: containerID),
|
|
keysToFetch: keys
|
|
)
|
|
|
|
let request = CNSaveRequest()
|
|
for existing in all {
|
|
guard let mutable = existing.mutableCopy() as? CNMutableContact else { continue }
|
|
var metadata = ContactMetadataDSL.parse(note: mutable.note)
|
|
let shouldBeMyCard = existing.identifier == contact.identifier ? !metadata.isMyCard : false
|
|
if metadata.isMyCard != shouldBeMyCard {
|
|
metadata.isMyCard = shouldBeMyCard
|
|
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
|
|
request.update(mutable)
|
|
}
|
|
}
|
|
|
|
try store.execute(request)
|
|
refresh()
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func delete(_ contact: CNContact) {
|
|
let mutableContact = contact.mutableCopy() as? CNMutableContact
|
|
guard let mutableContact else { return }
|
|
|
|
let request = CNSaveRequest()
|
|
request.delete(mutableContact)
|
|
|
|
do {
|
|
try store.execute(request)
|
|
refresh()
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func requestAccess() {
|
|
store.requestAccess(for: .contacts) { [weak self] _, error in
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
if let error {
|
|
self.lastError = error.localizedDescription
|
|
}
|
|
self.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadContacts() {
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
guard let self else { return }
|
|
let localStore = CNContactStore()
|
|
var fetched: [CNContact] = []
|
|
var fetchError: Error? = nil
|
|
do {
|
|
fetched = try Self.fetchContacts(using: localStore, keys: Self.baseKeys)
|
|
} catch {
|
|
fetchError = error
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
if let fetchError {
|
|
self.contacts = []
|
|
self.lastError = fetchError.localizedDescription
|
|
self.favoriteContactIdentifiers = []
|
|
self.myCardIdentifier = nil
|
|
} else {
|
|
self.contacts = fetched
|
|
self.supportsMetadataDSL = false
|
|
self.lastError = nil
|
|
|
|
if self.supportsMetadataDSL {
|
|
self.favoriteContactIdentifiers = Set(
|
|
fetched
|
|
.filter { ContactMetadataDSL.parse(note: $0.note).isFavorite }
|
|
.map(\.identifier)
|
|
)
|
|
|
|
self.myCardIdentifier = fetched.first(where: {
|
|
ContactMetadataDSL.parse(note: $0.note).isMyCard
|
|
})?.identifier
|
|
} else {
|
|
self.favoriteContactIdentifiers = []
|
|
self.myCardIdentifier = nil
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private static var baseKeys: [CNKeyDescriptor] {
|
|
[
|
|
CNContactViewController.descriptorForRequiredKeys(),
|
|
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
|
CNContactIdentifierKey as CNKeyDescriptor,
|
|
CNContactGivenNameKey as CNKeyDescriptor,
|
|
CNContactFamilyNameKey as CNKeyDescriptor,
|
|
CNContactNicknameKey as CNKeyDescriptor,
|
|
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
|
CNContactEmailAddressesKey as CNKeyDescriptor,
|
|
CNContactImageDataAvailableKey as CNKeyDescriptor,
|
|
CNContactThumbnailImageDataKey as CNKeyDescriptor
|
|
]
|
|
}
|
|
|
|
nonisolated private static func isUnauthorizedNoteError(_ error: NSError) -> Bool {
|
|
guard error.domain == CNErrorDomain,
|
|
error.code == 102 else {
|
|
return false
|
|
}
|
|
if let keyPaths = error.userInfo["CNKeyPaths"] as? [String] {
|
|
return keyPaths.contains("note")
|
|
}
|
|
return true
|
|
}
|
|
|
|
nonisolated private static func fetchContacts(using store: CNContactStore, keys: [CNKeyDescriptor]) throws -> [CNContact] {
|
|
var fetched: [CNContact] = []
|
|
let request = CNContactFetchRequest(keysToFetch: keys)
|
|
request.sortOrder = .userDefault
|
|
try store.enumerateContacts(with: request) { contact, _ in
|
|
fetched.append(contact)
|
|
}
|
|
return fetched
|
|
}
|
|
|
|
private func updateMetadata(for contact: CNContact, mutate: (inout ContactMetadata) -> Void) {
|
|
guard supportsMetadataDSL else {
|
|
lastError = String(localized: "contacts.metadata_unavailable")
|
|
return
|
|
}
|
|
|
|
do {
|
|
let keys: [CNKeyDescriptor] = [
|
|
CNContactIdentifierKey as CNKeyDescriptor,
|
|
CNContactNoteKey as CNKeyDescriptor
|
|
]
|
|
let fetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: keys)
|
|
guard let mutable = fetched.mutableCopy() as? CNMutableContact else { return }
|
|
|
|
var metadata = ContactMetadataDSL.parse(note: mutable.note)
|
|
mutate(&metadata)
|
|
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
|
|
|
|
let request = CNSaveRequest()
|
|
request.update(mutable)
|
|
try store.execute(request)
|
|
refresh()
|
|
} catch let error as NSError {
|
|
if Self.isUnauthorizedNoteError(error) {
|
|
supportsMetadataDSL = false
|
|
lastError = String(localized: "contacts.metadata_unavailable")
|
|
} else {
|
|
lastError = error.localizedDescription
|
|
}
|
|
} catch {
|
|
lastError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|