
Permission Priming
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates pre-permission priming screens that explain the benefit before iOS system permission dialogs to increase grant rates for camera, notifications, or location.
About
Generates pre-permission priming screens shown before iOS system dialogs to explain why the app needs access and raise grant rates. A developer uses it when requesting camera, notification, or location permissions.
- Priming screens before system permission dialogs
- Targets higher opt-in and grant rates
Permission Priming by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill permission-primingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates pre-permission priming screens that explain the benefit before iOS system permission dialogs to increase grant rates for camera, notifications, or location.
Files
Permission Priming Generator
Generate pre-permission priming screens — shown before iOS system permission dialogs to explain WHY the app needs access. Dramatically increases permission grant rates vs. cold-prompting users with the system alert.
When This Skill Activates
Use this skill when the user:
- Asks to "add permission priming" or "pre-permission screen"
- Mentions "explain permissions" or "permission request screen"
- Wants to "ask for camera permission" or "notification permission"
- Asks about "location permission explanation" or "permission grant rate"
- Wants to "prime before requesting access" or "improve opt-in rates"
- Mentions "permission dialog" or "permission flow"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Existing Permission Code
Search for existing permission handling:
Glob: **/*Permission*.swift, **/*Authorization*.swift
Grep: "requestAuthorization" or "AVCaptureDevice" or "UNUserNotificationCenter" or "CLLocationManager" or "PHPhotoLibrary"If existing permission code found:
- Ask if user wants to wrap existing calls with priming screens or replace entirely
- If wrapping, integrate priming views upstream of existing request calls
3. Info.plist Usage Description Strings
Search for required usage description keys:
Grep: "NSCameraUsageDescription" or "NSMicrophoneUsageDescription" or "NSLocationWhenInUseUsageDescription" or "NSPhotoLibraryUsageDescription" or "NSContactsUsageDescription" or "NSHealthShareUsageDescription"If missing keys found for requested permissions:
- Warn user that the app will crash without the corresponding Info.plist entry
- Provide the required key and a suggested description string
Configuration Questions
Ask user via AskUserQuestion:
1. Which permissions to prime? (multi-select)
- Push Notifications
- Camera
- Photo Library
- Location (When In Use)
- Location (Always)
- Microphone
- Contacts
- Health (HealthKit)
- App Tracking Transparency (ATT)
2. Priming style?
- Full-screen (dedicated view with illustration, title, benefits, CTA) — recommended
- Alert-style (compact alert-like overlay before system prompt)
- Inline (embedded card within existing content)
3. Include "Not Now" option?
- Yes (recommended — reduces user frustration, can re-prompt later)
- No (only "Enable" button — more aggressive)
4. Show benefits illustration?
- SF Symbol (simple, matches system style)
- Custom illustration area (placeholder for designer asset)
- None (text only)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. PermissionType.swift — Enum of all permission types with metadata 2. PermissionStatus.swift — Unified status enum wrapping platform-specific statuses 3. PermissionManager.swift — @Observable class that checks, requests, and opens Settings
Step 3: Create UI Files
4. PermissionPrimingView.swift — Pre-permission screen with benefits and CTA 5. PermissionStatusTracker.swift — Monitors status changes from Settings
Step 4: Create Modifier Files
6. PermissionGatedModifier.swift — ViewModifier that gates content behind permission check
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/Permissions/ - If
App/exists ->App/Permissions/ - Otherwise ->
Permissions/
Output Format
After generation, provide:
Files Created
Permissions/
├── PermissionType.swift # Permission enum with metadata
├── PermissionStatus.swift # Unified status wrapper
├── PermissionManager.swift # Check, request, open Settings
├── PermissionPrimingView.swift # Pre-permission priming screen
├── PermissionStatusTracker.swift # Monitor status changes
└── PermissionGatedModifier.swift # Gate content behind permissionIntegration Steps
Basic priming before system prompt:
// Show priming screen, then request system permission on tap
PermissionPrimingView(permissionType: .notifications) {
// User granted — proceed with feature
startSendingNotifications()
} onDenied: {
// User denied or tapped "Not Now"
showLaterPrompt()
}Gate a feature behind permission:
// Camera feature gated behind permission
CameraView()
.permissionGated(.camera) {
// Priming screen shown automatically if not yet authorized
}In onboarding flow:
struct OnboardingPermissionsView: View {
@State private var permissionManager = PermissionManager()
var body: some View {
VStack(spacing: 32) {
PermissionPrimingView(permissionType: .notifications) {
// Move to next permission
} onDenied: {
// Skip, ask later
}
}
}
}Check status and re-prompt after denial:
struct SettingsView: View {
@State private var permissionManager = PermissionManager()
var body: some View {
Section("Permissions") {
ForEach(PermissionType.allCases, id: \.self) { type in
PermissionRow(
type: type,
status: permissionManager.status(for: type),
onRequest: { permissionManager.openSettings() }
)
}
}
}
}Testing
@Test
func primingViewShowsBeforeSystemPrompt() async throws {
let manager = PermissionManager()
let status = await manager.status(for: .notifications)
#expect(status == .notDetermined)
// Priming view should appear before system dialog
}
@Test
func deniedPermissionDirectsToSettings() async throws {
let manager = PermissionManager()
// After denial, tapping "Enable" should open Settings
let settingsURL = manager.settingsURL
#expect(settingsURL != nil)
}
@Test
func permissionGatedModifierShowsPrimingWhenNotAuthorized() async throws {
// ViewModifier should show priming screen when permission is .notDetermined
// and show content when .authorized
}Common Patterns
Prime Before First Use
Show priming screen at the natural moment the user first needs the feature, not during onboarding:
// User taps "Take Photo" -> show camera priming -> then system prompt
Button("Take Photo") {
showCameraPriming = true
}
.sheet(isPresented: $showCameraPriming) {
PermissionPrimingView(permissionType: .camera,
onGranted: { openCamera() },
onDenied: { showCameraPriming = false })
}Contextual Priming
Explain the benefit in context of what the user is trying to do:
PermissionPrimingView(
permissionType: .location(.whenInUse),
customTitle: "Find Nearby Restaurants",
customDescription: "We use your location to show restaurants within walking distance."
) { ... }Re-Request After Denial
After denial, the system prompt cannot be shown again. Direct to Settings:
if permissionManager.status(for: .camera) == .denied {
// Show explanation + "Open Settings" button
PermissionDeniedView(permissionType: .camera) {
permissionManager.openSettings()
}
}Gotchas
- Cannot re-show system prompt after denial. Once the user denies a permission, iOS will never show the system dialog again. You must direct the user to Settings > Privacy to re-enable. Always handle the
.deniedstate gracefully. - App Tracking Transparency (ATT) has special requirements. ATT must be requested before tracking begins. The priming screen must NOT use misleading language. Apple reviews ATT implementations closely — avoid incentivizing users to allow tracking.
- Health permissions have unique two-column UI. HealthKit authorization uses its own system sheet with read/write toggles per data type. You cannot customize it. Your priming screen should explain which health data you need and why before presenting the HealthKit sheet.
- Location "Always" requires progressive authorization. You must first request
.whenInUse, then separately request.always. iOS shows a follow-up prompt only after the user has used the app with When In Use access. Do not request Always upfront. - Provisional notifications (iOS 12+) bypass the prompt entirely. With
.provisional, notifications are delivered silently to Notification Center without asking. Consider whether quiet delivery is acceptable before building a priming screen. - Info.plist keys are mandatory. If you call a permission API without the corresponding
NS*UsageDescriptionkey in Info.plist, the app will crash immediately. Always verify these keys exist.
References
- templates.md — All production Swift templates for permission priming
- Related:
generators/push-notifications— Push notification setup and handling - Related:
generators/consent-flow— GDPR/privacy consent flows
Permission Priming Code Templates
Production-ready Swift templates for pre-permission priming screens. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
PermissionType.swift
import Foundation
/// All permission types the app may request, with associated metadata.
///
/// Each case carries display information for the priming screen
/// and the corresponding Info.plist usage description key.
enum PermissionType: CaseIterable, Identifiable, Sendable {
case notifications
case camera
case photoLibrary
case location(LocationAccuracy)
case microphone
case contacts
case health
case tracking
enum LocationAccuracy: Sendable {
case whenInUse
case always
}
var id: String { infoPlistKey ?? title }
// MARK: - Display Metadata
/// Title shown on the priming screen.
var title: String {
switch self {
case .notifications: return "Enable Notifications"
case .camera: return "Allow Camera Access"
case .photoLibrary: return "Access Your Photos"
case .location(.whenInUse): return "Share Your Location"
case .location(.always): return "Enable Background Location"
case .microphone: return "Allow Microphone Access"
case .contacts: return "Access Your Contacts"
case .health: return "Connect Health Data"
case .tracking: return "Allow Tracking"
}
}
/// SF Symbol name for the priming illustration.
var systemImage: String {
switch self {
case .notifications: return "bell.badge.fill"
case .camera: return "camera.fill"
case .photoLibrary: return "photo.on.rectangle.angled"
case .location: return "location.fill"
case .microphone: return "mic.fill"
case .contacts: return "person.crop.circle.fill"
case .health: return "heart.fill"
case .tracking: return "hand.raised.fill"
}
}
/// Benefit description explaining WHY the app needs this permission.
/// Customize these strings per app for maximum grant rates.
var benefitDescription: String {
switch self {
case .notifications:
return "Get notified about important updates, reminders, and activity so you never miss what matters."
case .camera:
return "Take photos and scan documents directly within the app for a seamless experience."
case .photoLibrary:
return "Choose photos from your library to share, edit, or use as profile pictures."
case .location(.whenInUse):
return "Find nearby places, get directions, and see relevant content based on where you are."
case .location(.always):
return "Receive location-based alerts and updates even when the app is in the background."
case .microphone:
return "Record audio for voice messages, video calls, and voice commands."
case .contacts:
return "Find friends who are already using the app and easily share content with them."
case .health:
return "Sync your health and fitness data to provide personalized insights and track your progress."
case .tracking:
return "Allow us to deliver personalized ads and measure campaign effectiveness."
}
}
/// The corresponding Info.plist usage description key.
/// Returns nil for types that don't require an Info.plist entry (e.g., notifications).
var infoPlistKey: String? {
switch self {
case .notifications: return nil // No Info.plist key required
case .camera: return "NSCameraUsageDescription"
case .photoLibrary: return "NSPhotoLibraryUsageDescription"
case .location(.whenInUse): return "NSLocationWhenInUseUsageDescription"
case .location(.always): return "NSLocationAlwaysAndWhenInUseUsageDescription"
case .microphone: return "NSMicrophoneUsageDescription"
case .contacts: return "NSContactsUsageDescription"
case .health: return "NSHealthShareUsageDescription"
case .tracking: return "NSUserTrackingUsageDescription"
}
}
// MARK: - CaseIterable Conformance
/// All cases for iteration. Defaults to `.whenInUse` for location.
static var allCases: [PermissionType] {
[.notifications, .camera, .photoLibrary, .location(.whenInUse),
.location(.always), .microphone, .contacts, .health, .tracking]
}
}PermissionStatus.swift
import Foundation
/// Unified permission status that wraps platform-specific authorization statuses.
///
/// Normalizes the different status enums from AVFoundation, CoreLocation,
/// UserNotifications, Photos, etc. into a single type.
enum PermissionStatus: Sendable, Equatable {
/// User has not yet been asked. Priming screen should be shown.
case notDetermined
/// User granted access.
case authorized
/// User explicitly denied access. Must direct to Settings.
case denied
/// Access restricted by parental controls or device management.
case restricted
/// Notifications only: provisional (quiet) delivery authorized.
case provisional
/// Whether the permission has been granted (fully or provisionally).
var isGranted: Bool {
self == .authorized || self == .provisional
}
/// Whether the priming screen should be shown.
var shouldShowPriming: Bool {
self == .notDetermined
}
/// Whether the user must be directed to Settings to change the permission.
var requiresSettings: Bool {
self == .denied || self == .restricted
}
}PermissionManager.swift
import Foundation
import AVFoundation
import UserNotifications
import CoreLocation
import Photos
import Contacts
#if canImport(AppTrackingTransparency)
import AppTrackingTransparency
#endif
#if canImport(HealthKit)
import HealthKit
#endif
#if canImport(UIKit)
import UIKit
#endif
/// Manages checking and requesting all app permissions through a unified interface.
///
/// Usage:
/// ```swift
/// @State private var permissionManager = PermissionManager()
///
/// let status = await permissionManager.status(for: .camera)
/// if status == .notDetermined {
/// let granted = await permissionManager.request(.camera)
/// }
/// ```
@Observable
final class PermissionManager {
private let locationDelegate = LocationPermissionDelegate()
// MARK: - Check Status
/// Returns the current authorization status for a permission type.
@MainActor
func status(for type: PermissionType) async -> PermissionStatus {
switch type {
case .notifications:
return await notificationStatus()
case .camera:
return cameraStatus()
case .photoLibrary:
return photoLibraryStatus()
case .location(let accuracy):
return locationStatus(accuracy: accuracy)
case .microphone:
return microphoneStatus()
case .contacts:
return contactsStatus()
case .health:
return .notDetermined // HealthKit status is per-type; check specific data types
case .tracking:
return trackingStatus()
}
}
// MARK: - Request Permission
/// Requests a permission and returns whether it was granted.
@MainActor
@discardableResult
func request(_ type: PermissionType) async -> Bool {
switch type {
case .notifications:
return await requestNotifications()
case .camera:
return await requestCamera()
case .photoLibrary:
return await requestPhotoLibrary()
case .location(let accuracy):
return await requestLocation(accuracy: accuracy)
case .microphone:
return await requestMicrophone()
case .contacts:
return await requestContacts()
case .health:
return await requestHealth()
case .tracking:
return await requestTracking()
}
}
// MARK: - Open Settings
/// Opens the app's Settings page so the user can change a denied permission.
func openSettings() {
#if canImport(UIKit)
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
#elseif canImport(AppKit)
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy") else { return }
NSWorkspace.shared.open(url)
#endif
}
/// Returns the Settings URL for testing or custom UI.
var settingsURL: URL? {
#if canImport(UIKit)
return URL(string: UIApplication.openSettingsURLString)
#else
return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy")
#endif
}
// MARK: - Notifications
private func notificationStatus() async -> PermissionStatus {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .provisional: return .provisional
case .ephemeral: return .authorized
@unknown default: return .notDetermined
}
}
private func requestNotifications() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
return granted
} catch {
return false
}
}
// MARK: - Camera
private func cameraStatus() -> PermissionStatus {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .restricted: return .restricted
@unknown default: return .notDetermined
}
}
private func requestCamera() async -> Bool {
await AVCaptureDevice.requestAccess(for: .video)
}
// MARK: - Photo Library
private func photoLibraryStatus() -> PermissionStatus {
switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .restricted: return .restricted
case .limited: return .authorized // Treat limited as authorized
@unknown default: return .notDetermined
}
}
private func requestPhotoLibrary() async -> Bool {
let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
return status == .authorized || status == .limited
}
// MARK: - Location
private func locationStatus(accuracy: PermissionType.LocationAccuracy) -> PermissionStatus {
switch locationDelegate.manager.authorizationStatus {
case .notDetermined: return .notDetermined
case .authorizedWhenInUse:
return accuracy == .always ? .notDetermined : .authorized
case .authorizedAlways: return .authorized
case .denied: return .denied
case .restricted: return .restricted
@unknown default: return .notDetermined
}
}
private func requestLocation(accuracy: PermissionType.LocationAccuracy) async -> Bool {
await withCheckedContinuation { continuation in
locationDelegate.onAuthorizationChange = { status in
switch status {
case .authorizedWhenInUse:
continuation.resume(returning: accuracy == .whenInUse)
case .authorizedAlways:
continuation.resume(returning: true)
case .denied, .restricted:
continuation.resume(returning: false)
default:
break // Wait for a definitive status
}
}
switch accuracy {
case .whenInUse:
locationDelegate.manager.requestWhenInUseAuthorization()
case .always:
locationDelegate.manager.requestAlwaysAuthorization()
}
}
}
// MARK: - Microphone
private func microphoneStatus() -> PermissionStatus {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .restricted: return .restricted
@unknown default: return .notDetermined
}
}
private func requestMicrophone() async -> Bool {
await AVCaptureDevice.requestAccess(for: .audio)
}
// MARK: - Contacts
private func contactsStatus() -> PermissionStatus {
switch CNContactStore.authorizationStatus(for: .contacts) {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .restricted: return .restricted
@unknown default: return .notDetermined
}
}
private func requestContacts() async -> Bool {
do {
return try await CNContactStore().requestAccess(for: .contacts)
} catch {
return false
}
}
// MARK: - Health
private func requestHealth() async -> Bool {
#if canImport(HealthKit)
guard HKHealthStore.isHealthDataAvailable() else { return false }
let store = HKHealthStore()
// Customize these types per your app's needs
let readTypes: Set<HKObjectType> = [
HKObjectType.quantityType(forIdentifier: .stepCount)!
]
do {
try await store.requestAuthorization(toShare: [], read: readTypes)
return true
} catch {
return false
}
#else
return false
#endif
}
// MARK: - App Tracking Transparency
private func trackingStatus() -> PermissionStatus {
#if canImport(AppTrackingTransparency)
switch ATTrackingManager.trackingAuthorizationStatus {
case .notDetermined: return .notDetermined
case .authorized: return .authorized
case .denied: return .denied
case .restricted: return .restricted
@unknown default: return .notDetermined
}
#else
return .notDetermined
#endif
}
private func requestTracking() async -> Bool {
#if canImport(AppTrackingTransparency)
let status = await ATTrackingManager.requestTrackingAuthorization()
return status == .authorized
#else
return false
#endif
}
}
// MARK: - Location Delegate
/// Internal delegate for handling CLLocationManager authorization callbacks.
private final class LocationPermissionDelegate: NSObject, CLLocationManagerDelegate {
let manager = CLLocationManager()
var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)?
override init() {
super.init()
manager.delegate = self
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
onAuthorizationChange?(manager.authorizationStatus)
}
}PermissionPrimingView.swift
import SwiftUI
/// Pre-permission priming screen shown before the iOS system permission dialog.
///
/// Explains WHY the app needs the permission with an illustration,
/// benefit description, and clear call-to-action. Dramatically increases
/// grant rates compared to showing the system prompt cold.
///
/// Usage:
/// ```swift
/// PermissionPrimingView(permissionType: .notifications) {
/// // User granted permission
/// } onDenied: {
/// // User tapped "Not Now" or system denied
/// }
/// ```
struct PermissionPrimingView: View {
let permissionType: PermissionType
let onGranted: () -> Void
let onDenied: () -> Void
/// Optional overrides for custom copy.
var customTitle: String?
var customDescription: String?
var customImage: String?
@Environment(\.dismiss) private var dismiss
@State private var permissionManager = PermissionManager()
@State private var isRequesting = false
var body: some View {
VStack(spacing: 0) {
Spacer()
illustrationSection
titleSection
.padding(.top, 24)
descriptionSection
.padding(.top, 12)
Spacer()
buttonSection
.padding(.bottom, 16)
}
.padding(.horizontal, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#if canImport(UIKit)
.background(Color(uiColor: .systemBackground))
#endif
}
// MARK: - Sections
private var illustrationSection: some View {
Image(systemName: customImage ?? permissionType.systemImage)
.font(.system(size: 64))
.foregroundStyle(.tint)
.frame(width: 120, height: 120)
.background(
Circle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: 120, height: 120)
)
}
private var titleSection: some View {
Text(customTitle ?? permissionType.title)
.font(.title2)
.fontWeight(.bold)
.multilineTextAlignment(.center)
}
private var descriptionSection: some View {
Text(customDescription ?? permissionType.benefitDescription)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
private var buttonSection: some View {
VStack(spacing: 12) {
// Primary CTA
Button {
Task {
isRequesting = true
let granted = await permissionManager.request(permissionType)
isRequesting = false
if granted {
onGranted()
} else {
onDenied()
}
dismiss()
}
} label: {
if isRequesting {
ProgressView()
.frame(maxWidth: .infinity)
.frame(height: 22)
} else {
Text("Enable")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isRequesting)
// Secondary "Not Now"
Button {
onDenied()
dismiss()
} label: {
Text("Not Now")
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderless)
.controlSize(.large)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Convenience Initializer with Custom Copy
extension PermissionPrimingView {
/// Creates a priming view with custom title and description.
///
/// Use this to provide context-specific copy, e.g., "Find Nearby Restaurants"
/// instead of the generic "Share Your Location".
init(
permissionType: PermissionType,
customTitle: String,
customDescription: String,
customImage: String? = nil,
onGranted: @escaping () -> Void,
onDenied: @escaping () -> Void
) {
self.permissionType = permissionType
self.customTitle = customTitle
self.customDescription = customDescription
self.customImage = customImage
self.onGranted = onGranted
self.onDenied = onDenied
}
}
// MARK: - Denied State View
/// Shown when a permission was previously denied.
///
/// Explains that the user must go to Settings to re-enable,
/// and provides a direct button to open the Settings page.
struct PermissionDeniedView: View {
let permissionType: PermissionType
let onOpenSettings: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "gear.badge.xmark")
.font(.system(size: 56))
.foregroundStyle(.secondary)
Text("\(permissionType.title)")
.font(.title2)
.fontWeight(.bold)
.padding(.top, 24)
Text("You previously denied this permission. To enable it, open Settings and allow access.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 12)
Spacer()
Button {
onOpenSettings()
} label: {
Text("Open Settings")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.bottom, 16)
}
.padding(.horizontal, 32)
}
}PermissionStatusTracker.swift
import Foundation
import Combine
#if canImport(UIKit)
import UIKit
#endif
/// Monitors permission status changes and updates the UI reactively.
///
/// When a user changes a permission in Settings and returns to the app,
/// this tracker detects the change and updates the published status.
///
/// Usage:
/// ```swift
/// @State private var tracker = PermissionStatusTracker()
///
/// var body: some View {
/// Text("Camera: \(tracker.statuses[.camera]?.description ?? "unknown")")
/// .task { await tracker.startTracking([.camera, .notifications]) }
/// }
/// ```
@Observable
final class PermissionStatusTracker {
/// Current status for each tracked permission.
var statuses: [PermissionType: PermissionStatus] = [:]
private let permissionManager = PermissionManager()
private var isTracking = false
#if canImport(UIKit)
private var cancellables = Set<AnyCancellable>()
#endif
/// Start monitoring permission statuses for the given types.
///
/// Checks status immediately, then re-checks whenever the app
/// returns to the foreground (user may have changed permissions in Settings).
@MainActor
func startTracking(_ types: [PermissionType]) async {
guard !isTracking else { return }
isTracking = true
// Initial check
await refreshStatuses(for: types)
// Re-check when app returns from background (user may have visited Settings)
#if canImport(UIKit)
NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.sink { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.refreshStatuses(for: types)
}
}
.store(in: &cancellables)
#endif
}
/// Stop monitoring and clear stored statuses.
func stopTracking() {
isTracking = false
statuses.removeAll()
#if canImport(UIKit)
cancellables.removeAll()
#endif
}
/// Manually refresh statuses for the tracked permissions.
@MainActor
func refreshStatuses(for types: [PermissionType]) async {
for type in types {
statuses[type] = await permissionManager.status(for: type)
}
}
}PermissionGatedModifier.swift
import SwiftUI
/// A ViewModifier that gates content behind a permission check.
///
/// If the permission is authorized, the content is shown normally.
/// If not determined, the priming screen is shown automatically.
/// If denied, the denied view with a Settings link is shown.
///
/// Usage:
/// ```swift
/// CameraView()
/// .permissionGated(.camera)
///
/// // With custom priming copy:
/// MapView()
/// .permissionGated(
/// .location(.whenInUse),
/// title: "Find Nearby Coffee",
/// description: "We need your location to show cafes within walking distance."
/// )
/// ```
struct PermissionGatedModifier: ViewModifier {
let permissionType: PermissionType
var customTitle: String?
var customDescription: String?
@State private var permissionManager = PermissionManager()
@State private var currentStatus: PermissionStatus = .notDetermined
@State private var hasChecked = false
func body(content: Content) -> some View {
Group {
if !hasChecked {
ProgressView("Checking permissions...")
} else {
switch currentStatus {
case .authorized, .provisional:
content
case .notDetermined:
primingView
case .denied, .restricted:
deniedView
}
}
}
.task {
currentStatus = await permissionManager.status(for: permissionType)
hasChecked = true
}
#if canImport(UIKit)
.onReceive(
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
) { _ in
Task {
currentStatus = await permissionManager.status(for: permissionType)
}
}
#endif
}
@ViewBuilder
private var primingView: some View {
if let customTitle, let customDescription {
PermissionPrimingView(
permissionType: permissionType,
customTitle: customTitle,
customDescription: customDescription,
onGranted: { refreshStatus() },
onDenied: { refreshStatus() }
)
} else {
PermissionPrimingView(
permissionType: permissionType,
onGranted: { refreshStatus() },
onDenied: { refreshStatus() }
)
}
}
private var deniedView: some View {
PermissionDeniedView(permissionType: permissionType) {
permissionManager.openSettings()
}
}
private func refreshStatus() {
Task {
currentStatus = await permissionManager.status(for: permissionType)
}
}
}
// MARK: - View Extension
extension View {
/// Gates this view behind a permission check, showing a priming screen if needed.
///
/// - Parameters:
/// - type: The permission type to check.
/// - title: Optional custom title for the priming screen.
/// - description: Optional custom description for the priming screen.
func permissionGated(
_ type: PermissionType,
title: String? = nil,
description: String? = nil
) -> some View {
modifier(PermissionGatedModifier(
permissionType: type,
customTitle: title,
customDescription: description
))
}
}