All skills
rshankras avatar

/consent-flow

@32566aa

Generates GDPR/CCPA/DPDP privacy consent flows with granular category preferences, consent state persistence, audit logging, and ATT (App Tracking Transparency) integration. Use when user needs privacy consent UI, cookie/tracking consent, or compliance management.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/consent-flow

This session only. Nothing lands on disk.

templates.md

≈6.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Consent Flow Code Templates

Production-ready Swift templates for privacy consent management. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.

ConsentCategory.swift

import Foundation

/// Categories of data processing that require user consent.
///
/// Each category represents a distinct purpose for data collection.
/// Essential is always required and cannot be disabled by the user.
enum ConsentCategory: String, CaseIterable, Codable, Sendable, Identifiable {
    case essential
    case analytics
    case marketing
    case personalization
    case functional

    var id: String { rawValue }

    /// Human-readable display name.
    var displayName: String {
        switch self {
        case .essential: return "Essential"
        case .analytics: return "Analytics"
        case .marketing: return "Marketing"
        case .personalization: return "Personalization"
        case .functional: return "Functional"
        }
    }

    /// Explanation shown to users describing what this category covers.
    var explanation: String {
        switch self {
        case .essential:
            return "Required for the app to function. Includes authentication, security, and core features."
        case .analytics:
            return "Helps us understand how you use the app so we can improve it. Includes usage statistics and crash reports."
        case .marketing:
            return "Used for advertising and attribution. Allows us to measure ad effectiveness and show relevant ads."
        case .personalization:
            return "Enables personalized content and recommendations based on your usage patterns."
        case .functional:
            return "Remembers your preferences and settings to enhance your experience beyond essential functionality."
        }
    }

    /// Whether this category is required and cannot be disabled.
    var isRequired: Bool {
        self == .essential
    }

    /// Categories that require user consent (excludes essential).
    static var consentable: [ConsentCategory] {
        allCases.filter { !$0.isRequired }
    }
}

ConsentDecision.swift

import Foundation

/// The user's consent decision for a specific category.
enum ConsentStatus: String, Codable, Sendable {
    case granted
    case denied
    case notDetermined
}

/// A recorded consent decision with metadata.
struct ConsentDecision: Codable, Sendable, Equatable {
    let category: ConsentCategory
    let status: ConsentStatus
    let timestamp: Date
    let regulation: ConsentRegulation?

    init(
        category: ConsentCategory,
        status: ConsentStatus,
        timestamp: Date = Date(),
        regulation: ConsentRegulation? = nil
    ) {
        self.category = category
        self.status = status
        self.timestamp = timestamp
        self.regulation = regulation
    }
}

/// Supported privacy regulations.
enum ConsentRegulation: String, Codable, Sendable, CaseIterable {
    case gdpr    // EU General Data Protection Regulation
    case ccpa    // California Consumer Privacy Act
    case dpdp    // India Digital Personal Data Protection

    var displayName: String {
        switch self {
        case .gdpr: return "GDPR"
        case .ccpa: return "CCPA"
        case .dpdp: return "DPDP"
        }
    }

    /// Whether this regulation requires explicit opt-in consent.
    var requiresOptIn: Bool {
        switch self {
        case .gdpr: return true   // Must opt-in
        case .ccpa: return false  // Opt-out model
        case .dpdp: return true   // Must opt-in
        }
    }

    /// Minimum age for self-consent.
    var minimumConsentAge: Int {
        switch self {
        case .gdpr: return 16    // Member states can lower to 13
        case .ccpa: return 16    // 13 for sale-of-data specific consent
        case .dpdp: return 18
        }
    }
}

ConsentManager.swift

import Foundation
import AppTrackingTransparency

/// Manages user privacy consent state across the app.
///
/// Persists consent decisions in UserDefaults and integrates
/// with App Tracking Transparency for iOS.
///
/// Usage:
/// ```swift
/// @State private var consentManager = ConsentManager()
///
/// if consentManager.hasConsent(for: .analytics) {
///     trackEvent(.screenView)
/// }
/// ```
@Observable
final class ConsentManager {
    // MARK: - State

    private(set) var decisions: [ConsentCategory: ConsentDecision] = [:]
    private(set) var attStatus: ATTrackingManager.AuthorizationStatus = .notDetermined

    // MARK: - Dependencies

    private let defaults: UserDefaults
    private let auditLog: ConsentAuditLog
    private let regulation: ConsentRegulation?

    private static let storageKey = "com.app.consentDecisions"

    // MARK: - Computed Properties

    /// Whether the app needs to show the consent banner.
    ///
    /// Returns true if any non-essential category has not been decided.
    var needsConsent: Bool {
        ConsentCategory.consentable.contains { category in
            decisions[category]?.status == .notDetermined || decisions[category] == nil
        }
    }

    /// Summary of current consent state for display.
    var consentSummary: String {
        let granted = ConsentCategory.consentable.filter { hasConsent(for: $0) }
        return "\(granted.count) of \(ConsentCategory.consentable.count) optional categories enabled"
    }

    // MARK: - Initialization

    init(
        defaults: UserDefaults = .standard,
        auditLog: ConsentAuditLog = .shared,
        regulation: ConsentRegulation? = nil
    ) {
        self.defaults = defaults
        self.auditLog = auditLog
        self.regulation = regulation
        loadPersistedDecisions()
        ensureEssentialGranted()

        #if os(iOS)
        attStatus = ATTrackingManager.trackingAuthorizationStatus
        #endif
    }

    // MARK: - Consent Operations

    /// Check if the user has granted consent for a specific category.
    func hasConsent(for category: ConsentCategory) -> Bool {
        if category.isRequired { return true }
        return decisions[category]?.status == .granted
    }

    /// Update consent for a specific category.
    func updateConsent(for category: ConsentCategory, granted: Bool) {
        guard !category.isRequired else { return } // Essential cannot be changed

        let decision = ConsentDecision(
            category: category,
            status: granted ? .granted : .denied,
            regulation: regulation
        )

        decisions[category] = decision
        persistDecisions()

        auditLog.record(decision: decision)

        NotificationCenter.default.post(
            name: .consentDidChange,
            object: nil,
            userInfo: ["category": category, "granted": granted]
        )
    }

    /// Grant consent for all categories.
    func grantAll() {
        for category in ConsentCategory.consentable {
            updateConsent(for: category, granted: true)
        }
    }

    /// Deny consent for all non-essential categories.
    func denyAllNonEssential() {
        for category in ConsentCategory.consentable {
            updateConsent(for: category, granted: false)
        }
    }

    /// Reset all consent decisions, requiring re-consent.
    func resetAllConsent() {
        for category in ConsentCategory.consentable {
            let decision = ConsentDecision(
                category: category,
                status: .notDetermined,
                regulation: regulation
            )
            decisions[category] = decision
            auditLog.record(decision: decision)
        }
        persistDecisions()

        NotificationCenter.default.post(name: .consentDidChange, object: nil)
    }

    /// Execute a closure only if consent is granted for the given category.
    func executeIfConsented(
        category: ConsentCategory,
        action: () -> Void
    ) {
        guard hasConsent(for: category) else { return }
        action()
    }

    // MARK: - ATT Integration

    /// Request App Tracking Transparency permission.
    ///
    /// Should be called after showing your own consent explanation,
    /// not during app launch. Returns the authorization status.
    @MainActor
    func requestATTPermission() async -> ATTrackingManager.AuthorizationStatus {
        #if os(iOS)
        let status = await ATTrackingManager.requestTrackingAuthorization()
        attStatus = status

        // Sync ATT result with marketing consent
        switch status {
        case .authorized:
            updateConsent(for: .marketing, granted: true)
        case .denied, .restricted:
            updateConsent(for: .marketing, granted: false)
        case .notDetermined:
            break
        @unknown default:
            break
        }

        return status
        #else
        return .notDetermined
        #endif
    }

    /// Whether ATT has been requested and resolved.
    var isATTDetermined: Bool {
        #if os(iOS)
        return attStatus != .notDetermined
        #else
        return true
        #endif
    }

    // MARK: - Persistence

    private func loadPersistedDecisions() {
        guard let data = defaults.data(forKey: Self.storageKey),
              let decoded = try? JSONDecoder().decode(
                  [String: ConsentDecision].self, from: data
              ) else {
            initializeDefaultDecisions()
            return
        }

        decisions = Dictionary(
            uniqueKeysWithValues: decoded.compactMap { key, value in
                guard let category = ConsentCategory(rawValue: key) else { return nil }
                return (category, value)
            }
        )
    }

    private func persistDecisions() {
        let encoded = Dictionary(
            uniqueKeysWithValues: decisions.map { ($0.key.rawValue, $0.value) }
        )
        if let data = try? JSONEncoder().encode(encoded) {
            defaults.set(data, forKey: Self.storageKey)
        }
    }

    private func initializeDefaultDecisions() {
        for category in ConsentCategory.allCases {
            decisions[category] = ConsentDecision(
                category: category,
                status: category.isRequired ? .granted : .notDetermined
            )
        }
    }

    private func ensureEssentialGranted() {
        decisions[.essential] = ConsentDecision(
            category: .essential,
            status: .granted
        )
    }
}

// MARK: - Notifications

extension Notification.Name {
    /// Posted when any consent decision changes.
    ///
    /// UserInfo contains "category" (ConsentCategory) and "granted" (Bool).
    static let consentDidChange = Notification.Name("consentDidChange")
}

ConsentBannerView.swift

import SwiftUI

/// A bottom banner view requesting user consent for data processing.
///
/// Displays a brief explanation with three action buttons:
/// - Accept All: grants all consent categories
/// - Manage Preferences: opens detailed preferences view
/// - Reject Non-Essential: denies all optional categories
///
/// Usage:
/// ```swift
/// .overlay(alignment: .bottom) {
///     if consentManager.needsConsent {
///         ConsentBannerView()
///             .transition(.move(edge: .bottom).combined(with: .opacity))
///     }
/// }
/// ```
struct ConsentBannerView: View {
    @Environment(ConsentManager.self) private var consentManager
    @State private var showPreferences = false

    var body: some View {
        VStack(spacing: 16) {
            // Header
            HStack {
                Image(systemName: "hand.raised.fill")
                    .font(.title2)
                    .foregroundStyle(.tint)
                Text("Your Privacy Matters")
                    .font(.headline)
                Spacer()
            }

            // Explanation
            Text("We use cookies and similar technologies to improve your experience. You can choose which categories of data processing to allow.")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)

            // Action Buttons
            VStack(spacing: 10) {
                Button {
                    consentManager.grantAll()
                } label: {
                    Text("Accept All")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)

                HStack(spacing: 10) {
                    Button {
                        showPreferences = true
                    } label: {
                        Text("Manage Preferences")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.bordered)
                    .controlSize(.regular)

                    Button {
                        consentManager.denyAllNonEssential()
                    } label: {
                        Text("Reject Non-Essential")
                            .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.bordered)
                    .controlSize(.regular)
                }
            }

            // Privacy Policy Link
            Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .padding(20)
        .background {
            RoundedRectangle(cornerRadius: 16)
                #if os(iOS)
                .fill(.ultraThinMaterial)
                #else
                .fill(Color(nsColor: .controlBackgroundColor))
                #endif
                .shadow(color: .black.opacity(0.15), radius: 10, y: -5)
        }
        .padding(.horizontal, 16)
        .padding(.bottom, 8)
        .sheet(isPresented: $showPreferences) {
            ConsentPreferencesView()
        }
    }
}

ConsentPreferencesView.swift

import SwiftUI

/// Detailed consent preferences view with per-category toggles.
///
/// Shows each consent category with its description and a toggle.
/// Essential category is always on with a disabled toggle.
/// Provides Save and Cancel actions.
///
/// Usage:
/// ```swift
/// .sheet(isPresented: $showPreferences) {
///     ConsentPreferencesView()
///         .environment(consentManager)
/// }
/// ```
struct ConsentPreferencesView: View {
    @Environment(ConsentManager.self) private var consentManager
    @Environment(\.dismiss) private var dismiss

    @State private var pendingDecisions: [ConsentCategory: Bool] = [:]

    var body: some View {
        NavigationStack {
            List {
                Section {
                    explanationHeader
                }

                Section("Consent Categories") {
                    ForEach(ConsentCategory.allCases) { category in
                        ConsentCategoryRow(
                            category: category,
                            isEnabled: binding(for: category)
                        )
                    }
                }

                Section {
                    quickActions
                }

                Section {
                    privacyLinks
                }
            }
            .navigationTitle("Privacy Preferences")
            #if os(iOS)
            .navigationBarTitleDisplayMode(.inline)
            #endif
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        savePreferences()
                        dismiss()
                    }
                    .fontWeight(.semibold)
                }
            }
            .onAppear {
                loadCurrentDecisions()
            }
        }
    }

    // MARK: - Subviews

    private var explanationHeader: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Choose which types of data processing you consent to. Essential data processing cannot be disabled as it is required for the app to function.")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
    }

    private var quickActions: some View {
        VStack(spacing: 8) {
            Button("Enable All") {
                for category in ConsentCategory.consentable {
                    pendingDecisions[category] = true
                }
            }

            Button("Disable All Non-Essential") {
                for category in ConsentCategory.consentable {
                    pendingDecisions[category] = false
                }
            }
            .foregroundStyle(.secondary)
        }
    }

    private var privacyLinks: some View {
        VStack(alignment: .leading, spacing: 8) {
            Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
            Link("Terms of Service", destination: URL(string: "https://example.com/terms")!)
        }
        .font(.footnote)
    }

    // MARK: - Logic

    private func binding(for category: ConsentCategory) -> Binding<Bool> {
        Binding(
            get: { pendingDecisions[category] ?? category.isRequired },
            set: { newValue in
                guard !category.isRequired else { return }
                pendingDecisions[category] = newValue
            }
        )
    }

    private func loadCurrentDecisions() {
        for category in ConsentCategory.allCases {
            pendingDecisions[category] = consentManager.hasConsent(for: category)
        }
    }

    private func savePreferences() {
        for (category, granted) in pendingDecisions {
            guard !category.isRequired else { continue }
            consentManager.updateConsent(for: category, granted: granted)
        }
    }
}

// MARK: - Category Row

/// A single row in the consent preferences list.
struct ConsentCategoryRow: View {
    let category: ConsentCategory
    @Binding var isEnabled: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    HStack(spacing: 6) {
                        Text(category.displayName)
                            .font(.body.weight(.medium))
                        if category.isRequired {
                            Text("Required")
                                .font(.caption2)
                                .padding(.horizontal, 6)
                                .padding(.vertical, 2)
                                .background(Color.secondary.opacity(0.2))
                                .clipShape(Capsule())
                        }
                    }
                }
                Spacer()
                Toggle("", isOn: $isEnabled)
                    .labelsHidden()
                    .disabled(category.isRequired)
            }

            Text(category.explanation)
                .font(.caption)
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(.vertical, 4)
    }
}

ConsentAuditLog.swift

import Foundation

/// Records all consent decisions for compliance auditing.
///
/// Persists entries as JSON in the app's documents directory.
/// Supports export for data subject access requests (DSAR).
///
/// Usage:
/// ```swift
/// let log = ConsentAuditLog.shared
/// log.record(decision: decision)
/// let jsonData = try log.exportJSON()
/// ```
final class ConsentAuditLog: Sendable {
    static let shared = ConsentAuditLog()

    private let fileURL: URL
    private let queue = DispatchQueue(label: "com.app.consentAuditLog", qos: .utility)

    // MARK: - Audit Entry

    struct Entry: Codable, Sendable, Identifiable {
        let id: UUID
        let category: ConsentCategory
        let status: ConsentStatus
        let regulation: ConsentRegulation?
        let timestamp: Date
        let appVersion: String
        let osVersion: String

        init(
            category: ConsentCategory,
            status: ConsentStatus,
            regulation: ConsentRegulation?,
            timestamp: Date = Date()
        ) {
            self.id = UUID()
            self.category = category
            self.status = status
            self.regulation = regulation
            self.timestamp = timestamp
            self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
            self.osVersion = ProcessInfo.processInfo.operatingSystemVersionString
        }
    }

    // MARK: - Initialization

    init(directory: URL? = nil) {
        let dir = directory ?? FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
        self.fileURL = dir.appendingPathComponent("consent_audit_log.json")
    }

    // MARK: - Recording

    /// Record a consent decision in the audit log.
    func record(decision: ConsentDecision) {
        let entry = Entry(
            category: decision.category,
            status: decision.status,
            regulation: decision.regulation
        )
        record(entry: entry)
    }

    /// Record a consent decision by components.
    func record(
        category: ConsentCategory,
        granted: Bool,
        regulation: ConsentRegulation? = nil
    ) {
        let entry = Entry(
            category: category,
            status: granted ? .granted : .denied,
            regulation: regulation
        )
        record(entry: entry)
    }

    private func record(entry: Entry) {
        queue.async { [fileURL] in
            var entries = Self.loadEntries(from: fileURL)
            entries.append(entry)
            Self.saveEntries(entries, to: fileURL)
        }
    }

    // MARK: - Querying

    /// All recorded audit entries, sorted by timestamp (newest first).
    func allEntries() -> [Entry] {
        Self.loadEntries(from: fileURL).sorted { $0.timestamp > $1.timestamp }
    }

    /// Entries filtered by category.
    func entries(for category: ConsentCategory) -> [Entry] {
        allEntries().filter { $0.category == category }
    }

    /// The most recent decision for each category.
    func currentDecisions() -> [ConsentCategory: Entry] {
        var result: [ConsentCategory: Entry] = [:]
        for entry in allEntries() {
            if result[entry.category] == nil {
                result[entry.category] = entry
            }
        }
        return result
    }

    // MARK: - Export

    /// Export the full audit log as JSON data.
    ///
    /// Use for data subject access requests (DSAR) or compliance reporting.
    func exportJSON() throws -> Data {
        let entries = allEntries()
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        return try encoder.encode(entries)
    }

    /// Export as a human-readable string for display.
    func exportReadable() -> String {
        let entries = allEntries()
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium

        return entries.map { entry in
            let action = entry.status == .granted ? "Granted" : "Denied"
            let date = formatter.string(from: entry.timestamp)
            let reg = entry.regulation?.displayName ?? "—"
            return "[\(date)] \(action) \(entry.category.displayName) (Regulation: \(reg), App: \(entry.appVersion))"
        }.joined(separator: "\n")
    }

    // MARK: - Maintenance

    /// Remove entries older than the specified retention period.
    ///
    /// Default retention: 3 years (GDPR recommendation).
    func pruneOldEntries(olderThan retention: TimeInterval = 3 * 365 * 24 * 3600) {
        queue.async { [fileURL] in
            let cutoff = Date().addingTimeInterval(-retention)
            var entries = Self.loadEntries(from: fileURL)
            entries.removeAll { $0.timestamp < cutoff }
            Self.saveEntries(entries, to: fileURL)
        }
    }

    /// Delete the entire audit log.
    func deleteAll() {
        queue.async { [fileURL] in
            try? FileManager.default.removeItem(at: fileURL)
        }
    }

    // MARK: - File Operations

    private static func loadEntries(from fileURL: URL) -> [Entry] {
        guard let data = try? Data(contentsOf: fileURL) else { return [] }
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return (try? decoder.decode([Entry].self, from: data)) ?? []
    }

    private static func saveEntries(_ entries: [Entry], to fileURL: URL) {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        guard let data = try? encoder.encode(entries) else { return }
        try? data.write(to: fileURL, options: .atomic)
    }
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill is a code generation utility for creating privacy consent flows (GDPR, CCPA, DPDP) in Swift applications. It follows best practices for privacy management and does not exhibit any malicious behaviors.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/consent-flow