# PermissionKit patterns

Longer examples that back the main `permissionkit` skill. They assume the iOS
26.2 SDK (macOS 26.2 for the AppKit section) unless marked otherwise; the
26.0 pieces (`CommunicationLimits`, the topic and question types) work
earlier. Check signatures against the SDK you build with.

One rule runs through every example: a child who cancels the iMessage send
sheet produces no response at all. Pending state therefore has to be explicit
and has to be able to expire.

## Contents

- [UIKit view controller](#uikit-view-controller)
- [Response manager](#response-manager)
- [Several contacts in one question](#several-contacts-in-one-question)
- [Limits-check view model](#limits-check-view-model)
- [SwiftUI full flow](#swiftui-full-flow)
- [macOS](#macos)
- [Error recovery](#error-recovery)

## UIKit view controller

The controller owns one long-lived observer task, started when the view loads
and cancelled when the controller goes away.

```swift
import UIKit
@preconcurrency import PermissionKit

@available(iOS 26.2, *)
final class TeammateViewController: UIViewController {
    private var answerTask: Task<Void, Never>?
    private let teammate: Teammate

    init(teammate: Teammate) {
        self.teammate = teammate
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { nil }

    override func viewDidLoad() {
        super.viewDidLoad()
        answerTask = Task { [weak self] in
            let answers = AskCenter.shared.responses(for: CommunicationTopic.self)
            for await answer in answers {
                await MainActor.run { self?.apply(answer) }
            }
        }
    }

    deinit { answerTask?.cancel() }

    func askToMessage() {
        let person = CommunicationTopic.PersonInformation(
            handle: CommunicationHandle(value: teammate.phone, kind: .phoneNumber),
            nameComponents: teammate.name,
            avatarImage: teammate.avatarCGImage
        )
        let topic = CommunicationTopic(personInformation: [person], actions: [.message])
        let request = PermissionQuestion<CommunicationTopic>(communicationTopic: topic)

        Task {
            do {
                try await AskCenter.shared.ask(request, in: self as UIViewController)
                showWaitingBadge()
                scheduleExpiry(for: request.id)
            } catch AskError.communicationLimitsNotEnabled {
                enableMessaging(for: person.handle)
            } catch AskError.notAvailable {
                showUnavailableBanner()
            } catch {
                showError(error)
            }
        }
    }

    @MainActor
    private func apply(_ answer: PermissionResponse<CommunicationTopic>) {
        let people = answer.question.topic.personInformation
        for handle in people.map(\.handle) {
            switch answer.choice.answer {
            case .approval: enableMessaging(for: handle)
            case .denial: showDenied(for: handle)
            @unknown default: showDenied(for: handle)
            }
        }
    }
}
```

`avatarImage` takes a `CGImage`, so convert from `UIImage` with `.cgImage`
before building the person.

## Response manager

A single observable object keeps approval state for the whole app, keyed by
handle value, plus the ids of questions still waiting.

```swift
import Observation
@preconcurrency import PermissionKit
import UIKit

@available(iOS 26.2, *)
@Observable @MainActor
final class ContactApprovals {
    static let shared = ContactApprovals()

    private(set) var approved: Set<String> = []
    private(set) var denied: Set<String> = []
    private(set) var waitingValues: Set<String> = []
    private(set) var waitingQuestions: Set<UUID> = []

    func request(_ handles: [CommunicationHandle],
                 actions: Set<CommunicationTopic.Action>,
                 in host: UIViewController) async throws {
        let people = handles.map {
            CommunicationTopic.PersonInformation(handle: $0, nameComponents: nil, avatarImage: nil)
        }
        let question = PermissionQuestion<CommunicationTopic>(
            communicationTopic: CommunicationTopic(personInformation: people, actions: actions)
        )
        try await AskCenter.shared.ask(question, in: host as UIViewController)
        waitingQuestions.insert(question.id)
        waitingValues.formUnion(handles.map(\.value))
        scheduleExpiry(question.id, values: handles.map(\.value))
    }

    func isApproved(_ value: String) -> Bool { approved.contains(value) }
    func isDenied(_ value: String) -> Bool { denied.contains(value) }
    func isWaiting(_ value: String) -> Bool { waitingValues.contains(value) }

    func consume(_ response: PermissionResponse<CommunicationTopic>) {
        let people = response.question.topic.personInformation
        let values = people.map { $0.handle.value }
        waitingQuestions.remove(response.question.id)
        waitingValues.subtract(values)
        switch response.choice.answer {
        case .approval:
            approved.formUnion(values)
            denied.subtract(values)
        case .denial:
            denied.formUnion(values)
        @unknown default:
            denied.formUnion(values)
        }
    }

    private func scheduleExpiry(_ id: UUID, values: [String]) {
        // Product decision: how long a request may stay open before the
        // child can ask again.
        Task {
            try? await ContinuousClock().sleep(until: .now + .seconds(172_800))
            guard waitingQuestions.remove(id) != nil else { return }
            waitingValues.subtract(values)
        }
    }
}
```

The pending window is a product choice. Pick one, then either expire the
request or offer a retry when it runs out.

## Several contacts in one question

One question can cover a whole group. Here the handles are `.custom` values
built from contact identifiers, and the question allows three kinds of
contact:

```swift
func groupQuestion(for memberIDs: [String]) -> PermissionQuestion<CommunicationTopic> {
    let people = memberIDs.map {
        CommunicationTopic.PersonInformation(handle: CommunicationHandle(value: $0, kind: .custom))
    }
    let topic = CommunicationTopic(personInformation: people,
                                   actions: [.message, .audioCall, .videoCall])
    return PermissionQuestion(communicationTopic: topic)
}

let q = groupQuestion(for: ["member-8", "member-9"])
print(q.id)                              // UUID for tracking
print(q.choices.map(\.title))            // the options the parent sees
print(q.defaultChoice.title)
print(q.expirationDate as Any)           // nil unless set
```

A question exposes `id`, `choices` (each with a `title`), `defaultChoice` and
an optional, settable `expirationDate`; after that date the parent can no
longer respond.

## Limits-check view model

This view model sorts a contact list into "the system already knows this
handle" and "we will need to ask". It says nothing about whether limits are
enabled; that only shows up as `AskError.communicationLimitsNotEnabled` when
you ask.

```swift
import Observation
@preconcurrency import PermissionKit

struct RosterEntry: Identifiable {
    let id: String
    let handle: CommunicationHandle
    var isKnownBySystem = false
    var needsPermissionPrompt = true
}

@Observable @MainActor
final class RosterModel {
    var entries: [RosterEntry] = []

    func classify() async {
        let appID = Bundle.main.bundleIdentifier ?? ""
        if appID.isEmpty { return }
        let known = await CommunicationLimits.current.knownHandles(in: Set(entries.map(\.handle)))
        for index in entries.indices {
            let isKnown = known.contains(entries[index].handle)
            entries[index].isKnownBySystem = isKnown
            entries[index].needsPermissionPrompt = !isKnown
        }
    }
}
```

## SwiftUI full flow

A screen that works out its state on appear, then shows either a
`PermissionButton`, a waiting label or the final result. The manager is the
`ContactApprovals` type above, injected through the environment.

```swift
import SwiftUI
@preconcurrency import PermissionKit

@available(iOS 26.2, *)
struct TeammateAccessView: View {
    enum Phase: Equatable {
        case unknown, checking, needsPermission, pending, approved, denied
        case error(String)
    }

    @Environment(ContactApprovals.self) private var approvals
    let handle: CommunicationHandle
    let displayName: PersonNameComponents
    @State private var phase: Phase = .unknown

    var body: some View {
        Group {
            switch phase {
            case .unknown, .checking:
                ProgressView()
            case .needsPermission:
                VStack(spacing: 12) {
                    Text("A parent or guardian needs to allow chatting with this teammate.")
                        .multilineTextAlignment(.center)
                    PermissionButton(question: question) {
                        Label("Ask a parent", systemImage: "figure.and.child.holdinghands")
                    }
                    .buttonStyle(.borderedProminent)
                }
            case .pending:
                Label("Waiting for a parent", systemImage: "hourglass")
            case .approved:
                Label("Chat allowed", systemImage: "checkmark.circle")
            case .denied:
                Label("Chat not allowed", systemImage: "xmark.circle")
            case .error(let message):
                Label(message, systemImage: "exclamationmark.triangle")
            }
        }
        .task { await resolvePhase() }
    }

    private var question: PermissionQuestion<CommunicationTopic> {
        let person = CommunicationTopic.PersonInformation(handle: handle, nameComponents: displayName)
        return PermissionQuestion(communicationTopic: CommunicationTopic(personInformation: [person],
                                                                         actions: [.chat]))
    }

    private func resolvePhase() async {
        phase = .checking
        if await CommunicationLimits.current.isKnownHandle(handle) {
            phase = .approved
        } else if approvals.isApproved(handle.value) {
            phase = .approved
        } else if approvals.isDenied(handle.value) {
            phase = .denied
        } else if approvals.isWaiting(handle.value) {
            phase = .pending
        } else {
            phase = .needsPermission
        }
    }
}
```

## macOS

On macOS 26.2 and later the ask call takes an `NSWindow` rather than a view
controller:

```swift
#if os(macOS)
import AppKit
@preconcurrency import PermissionKit

@available(macOS 26.2, *)
@MainActor
func ask(_ question: PermissionQuestion<CommunicationTopic>, from window: NSWindow) async throws {
    try await AskCenter.shared.ask(question, in: window as NSWindow)
}
#endif
```

## Error recovery

Map every `AskError` case to something the user can read and, where it makes
sense, act on.

```swift
@preconcurrency import PermissionKit

struct RecoveryAdvice {
    let title: String
    let message: String
    let action: Action?
    enum Action { case openSettings, retry }
}

@available(iOS 26.2, *)
func advice(for error: AskError) -> RecoveryAdvice {
    switch error {
    case .communicationLimitsNotEnabled:
        RecoveryAdvice(title: "No approval needed",
                       message: "You can start chatting right away.", action: nil)
    case .contactSyncNotSetup:
        RecoveryAdvice(title: "Contacts not synced",
                       message: "Turn on contact sync so a parent can review this request.",
                       action: .openSettings)
    case .invalidQuestion:
        RecoveryAdvice(title: "Request could not be built",
                       message: "Please try again.", action: .retry)
    case .notAvailable:
        RecoveryAdvice(title: "Not available here",
                       message: "This device cannot send approval requests.", action: nil)
    case .systemError(let underlying):
        RecoveryAdvice(title: "Something went wrong",
                       message: underlying.localizedDescription, action: .retry)
    case .unknown:
        RecoveryAdvice(title: "Request failed",
                       message: "Please try again later.", action: .retry)
    @unknown default:
        RecoveryAdvice(title: "Request failed",
                       message: "Please try again later.", action: .retry)
    }
}
```

`.systemError` is deprecated in the 26.2 SDK (the underlying error is now
thrown on its own), so pair this mapping with a plain `catch` for non-`AskError`
failures.
