# Contacts patterns

Longer examples for the `contacts-framework` skill ([../SKILL.md](../SKILL.md)).

## SwiftUI integration

### An observable address-book model

```swift
@preconcurrency import Contacts
import Observation

@Observable
@MainActor
final class AddressBookModel {
    @ObservationIgnored let book = CNContactStore()
    private(set) var people: [CNContact] = []
    private(set) var canRead = false
    private(set) var isLimited = false
    private(set) var status = CNContactStore.authorizationStatus(for: .contacts)

    func refreshStatus() {
        status = CNContactStore.authorizationStatus(for: .contacts)
        canRead = status == .authorized || status == .limited
        isLimited = status == .limited
    }

    func askForAccess() async {
        _ = try? await book.requestAccess(for: .contacts)
        refreshStatus()                     // the status is the source of truth, not the Bool
    }

    func reload() async {
        guard canRead else { return }
        let keys: [CNKeyDescriptor] = [
            CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
            CNContactPhoneNumbersKey as CNKeyDescriptor,
            CNContactEmailAddressesKey as CNKeyDescriptor,
            CNContactThumbnailImageDataKey as CNKeyDescriptor
        ]
        let loaded = try? await Task.detached { [book] in
            let request = CNContactFetchRequest(keysToFetch: keys)
            request.sortOrder = .userDefault
            var rows: [CNContact] = []
            try book.enumerateContacts(with: request) { person, _ in rows.append(person) }
            return rows
        }.value
        people = loaded ?? []
    }

    func label(for person: CNContact) -> String {
        CNContactFormatter.string(from: person, style: .fullName)
            ?? "\(person.givenName) \(person.familyName)"
    }
}
```

### A list that respects limited access

```swift
import ContactsUI
import SwiftUI

struct PeopleList: View {
    @Environment(AddressBookModel.self) private var model
    @State private var managingAccess = false
    private let avatarSide: CGFloat = 40

    var body: some View {
        Group {
            if model.canRead {
                List(model.people, id: \.identifier) { person in
                    HStack {
                        avatar(for: person)
                        VStack(alignment: .leading) {
                            Text(model.label(for: person))
                            if let phone = person.phoneNumbers.first?.value.stringValue {
                                Text(phone)
                                    .font(.footnote)
                                    .foregroundStyle(.tertiary)
                            }
                        }
                    }
                }
                .toolbar {
                    if model.isLimited {
                        Button("Share More Contacts") { managingAccess = true }
                    }
                }
                .contactAccessPicker(isPresented: $managingAccess) { newlyShared in
                    if !newlyShared.isEmpty { Task { await model.reload() } }
                }
            } else {
                ContentUnavailableView {
                    Label("Contacts Unavailable", systemImage: "person.crop.circle.badge.xmark")
                } actions: {
                    Button("Allow Access") { Task { await model.askForAccess() } }
                        .buttonStyle(.borderedProminent)
                }
            }
        }
        .task {
            model.refreshStatus()
            await model.reload()
        }
    }

    @ViewBuilder
    private func avatar(for person: CNContact) -> some View {
        if let data = person.thumbnailImageData, let image = UIImage(data: data) {
            Image(uiImage: image)
                .resizable()
                .scaledToFill()
                .frame(width: avatarSide, height: avatarSide)
                .clipShape(.circle)
        } else {
            Image(systemName: "person.circle")
                .resizable()
                .frame(width: avatarSide, height: avatarSide)
                .foregroundStyle(.gray)
        }
    }
}
```

Two ways to widen limited access:

- `ContactAccessButton` fits inline, for example next to a search field, and
  offers matching contacts the app cannot see yet.
- `contactAccessPicker(isPresented:completionHandler:)` opens a management
  sheet; its completion receives only the identifiers granted in that
  session.

## Selecting several contacts

Implementing the array variant of the delegate method switches the picker
into multi-select mode:

```swift
final class GuestListCoordinator: NSObject, CNContactPickerDelegate {
    var onChoose: ([CNContact]) -> Void = { _ in }

    func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) {
        onChoose(contacts)   // the array overload is what turns on multi-select
    }
}
```

### Picking just an email address

```swift
@MainActor
func makeEmailPicker(delegate: CNContactPickerDelegate) -> CNContactPickerViewController {
    let picker = CNContactPickerViewController()
    picker.delegate = delegate
    let hasEmail = NSPredicate(format: "emailAddresses.@count >= 1")
    let emailRowOnly = NSPredicate(format: "key == %@", CNContactEmailAddressesKey)
    picker.predicateForEnablingContact = hasEmail
    picker.predicateForSelectionOfProperty = emailRowOnly
    picker.displayedPropertyKeys = [CNContactEmailAddressesKey]
    return picker
}

final class InviteCoordinator: NSObject, CNContactPickerDelegate {
    var onAddress: (String) -> Void = { _ in }

    func contactPicker(_ picker: CNContactPickerViewController,
                       didSelect contactProperty: CNContactProperty) {
        if let address = contactProperty.value as? String { onAddress(address) }
    }
}
```

## Searching

### Built-in predicates

```swift
let named = CNContact.predicateForContacts(matchingName: "Lindqvist")
let byEmail = CNContact.predicateForContacts(matchingEmailAddress: "ops@harbor.example")
let byPhone = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: "+1 415 555 0100"))
let byIDs = CNContact.predicateForContacts(withIdentifiers: pinnedIDs)
let inGroup = CNContact.predicateForContactsInGroup(withIdentifier: teamGroupID)
let inContainer = CNContact.predicateForContactsInContainer(withIdentifier: book.defaultContainerIdentifier())
```

### Filtering in memory

When no predicate fits, enumerate with the needed key and filter yourself:

```swift
func birthdaysThisMonth(in book: CNContactStore) throws -> [CNContact] {
    let month = Calendar.current.component(.month, from: .now)
    let request = CNContactFetchRequest(keysToFetch: [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactBirthdayKey as CNKeyDescriptor
    ])
    var matches: [CNContact] = []
    try book.enumerateContacts(with: request) { person, _ in
        if person.birthday?.month == month { matches.append(person) }
    }
    return matches
}
```

## vCard

```swift
func exportCards(_ people: [CNContact], to file: URL) throws {
    let cards = try CNContactVCardSerialization.data(with: people)
    try cards.write(to: file)
}

func importCards(from file: URL, into book: CNContactStore) throws {
    let parsed = try CNContactVCardSerialization.contacts(with: Data(contentsOf: file))
    let batch = CNSaveRequest()
    for card in parsed {
        if let copy = card.mutableCopy() as? CNMutableContact {
            batch.add(copy, toContainerWithIdentifier: nil)
        }
    }
    try book.execute(batch)
}
```

## Groups

```swift
let allGroups = try book.groups(matching: nil)
if let crew = allGroups.first(where: { $0.name == "Sailing Crew" }) {
    let members = try book.unifiedContacts(
        matching: CNContact.predicateForContactsInGroup(withIdentifier: crew.identifier),
        keysToFetch: [CNContactGivenNameKey as CNKeyDescriptor]
    )
    print(members.count)
}

let club = CNMutableGroup()
club.name = "Book Club"
let makeGroup = CNSaveRequest()
makeGroup.add(club, toContainerWithIdentifier: nil)
try book.execute(makeGroup)

let membership = CNSaveRequest()
membership.addMember(newMember, to: club)
membership.removeMember(formerMember, from: club)
try book.execute(membership)
```

## Change notifications and what Swift can do

In Swift, `CNContactStoreDidChange` is the tool: drop cached contacts and
fetch again whatever the current authorization allows.

```swift
func observeAddressBook(_ onChange: @escaping @Sendable () -> Void) -> NSObjectProtocol {
    NotificationCenter.default.addObserver(forName: .CNContactStoreDidChange,
                                           object: nil,
                                           queue: .main) { _ in onChange() }
}
```

Keep the returned token and pass it to `removeObserver(_:)` when done.

There is no `store.enumerateChanges(matching:)` method; code that calls it
will not compile. Change history is exposed only through the Objective-C
selector `enumeratorForChangeHistoryFetchRequest:error:`, which the SDK marks
unavailable in Swift. If the app truly needs incremental history tokens,
isolate an Objective-C bridge behind a small Swift wrapper. Otherwise,
refetch when the notification arrives.
