ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
276 lines
9.0 KiB
Swift
276 lines
9.0 KiB
Swift
#if canImport(SwiftUI)
|
|
import SwiftUI
|
|
import ConsentOSCore
|
|
|
|
// MARK: - Consent Banner View
|
|
|
|
/// A SwiftUI consent banner that respects the site's ``BannerTheme``.
|
|
///
|
|
/// The banner displays in a bottom-sheet style by default. It presents three
|
|
/// actions: accept all, reject all, and manage preferences (category-level toggles).
|
|
///
|
|
/// Usage:
|
|
/// ```swift
|
|
/// ConsentBannerView(theme: theme) {
|
|
/// await ConsentOS.shared.acceptAll()
|
|
/// } onRejectAll: {
|
|
/// await ConsentOS.shared.rejectAll()
|
|
/// } onSave: { categories in
|
|
/// await ConsentOS.shared.acceptCategories(categories)
|
|
/// }
|
|
/// ```
|
|
public struct ConsentBannerView: View {
|
|
|
|
// MARK: - State
|
|
|
|
@State private var showingManage: Bool = false
|
|
@State private var categoryToggles: [ConsentCategory: Bool] = [:]
|
|
|
|
// MARK: - Properties
|
|
|
|
private let theme: BannerTheme
|
|
private let onAcceptAll: () async -> Void
|
|
private let onRejectAll: () async -> Void
|
|
private let onSave: (Set<ConsentCategory>) async -> Void
|
|
|
|
/// The categories available for granular control (excludes `necessary`).
|
|
private let manageableCategories: [ConsentCategory] = ConsentCategory.allCases
|
|
.filter { $0.requiresConsent }
|
|
|
|
// MARK: - Initialisers
|
|
|
|
public init(
|
|
theme: BannerTheme = .defaultTheme,
|
|
onAcceptAll: @escaping () async -> Void,
|
|
onRejectAll: @escaping () async -> Void,
|
|
onSave: @escaping (Set<ConsentCategory>) async -> Void
|
|
) {
|
|
self.theme = theme
|
|
self.onAcceptAll = onAcceptAll
|
|
self.onRejectAll = onRejectAll
|
|
self.onSave = onSave
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 0) {
|
|
if showingManage {
|
|
manageView
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
} else {
|
|
mainBannerView
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
.background(theme.backgroundColor)
|
|
.clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius, style: .continuous))
|
|
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: -4)
|
|
.padding(.horizontal, theme.horizontalPadding)
|
|
.onAppear {
|
|
// Default all optional categories to off
|
|
for category in manageableCategories {
|
|
categoryToggles[category] = false
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: showingManage)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel("Cookie consent banner")
|
|
}
|
|
|
|
// MARK: - Main Banner
|
|
|
|
private var mainBannerView: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(theme.title)
|
|
.font(theme.titleFont)
|
|
.foregroundColor(theme.textColor)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
Text(theme.description)
|
|
.font(theme.bodyFont)
|
|
.foregroundColor(theme.secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Divider()
|
|
.background(theme.dividerColor)
|
|
|
|
// Action buttons
|
|
VStack(spacing: 8) {
|
|
acceptButton
|
|
HStack(spacing: 8) {
|
|
rejectButton
|
|
manageButton
|
|
}
|
|
}
|
|
}
|
|
.padding(theme.verticalPadding)
|
|
}
|
|
|
|
// MARK: - Manage View
|
|
|
|
private var manageView: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header
|
|
HStack {
|
|
Button(action: { showingManage = false }) {
|
|
Image(systemName: "chevron.left")
|
|
.foregroundColor(theme.accentColor)
|
|
}
|
|
.accessibilityLabel("Back")
|
|
|
|
Text(theme.manageButtonText)
|
|
.font(theme.titleFont)
|
|
.foregroundColor(theme.textColor)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(theme.verticalPadding)
|
|
|
|
Divider().background(theme.dividerColor)
|
|
|
|
// Necessary category — always on
|
|
categoryRow(
|
|
name: ConsentCategory.necessary.displayName,
|
|
description: ConsentCategory.necessary.displayDescription,
|
|
isOn: .constant(true),
|
|
isToggleable: false
|
|
)
|
|
|
|
Divider().background(theme.dividerColor)
|
|
|
|
// Optional categories
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ForEach(manageableCategories, id: \.rawValue) { category in
|
|
categoryRow(
|
|
name: category.displayName,
|
|
description: category.displayDescription,
|
|
isOn: Binding(
|
|
get: { categoryToggles[category] ?? false },
|
|
set: { categoryToggles[category] = $0 }
|
|
),
|
|
isToggleable: true
|
|
)
|
|
Divider().background(theme.dividerColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save button
|
|
Button(action: {
|
|
Task {
|
|
let selected = Set(
|
|
manageableCategories.filter { categoryToggles[$0] == true }
|
|
)
|
|
await onSave(selected)
|
|
}
|
|
}) {
|
|
Text("Save Preferences")
|
|
.font(theme.buttonFont)
|
|
.foregroundColor(theme.acceptButtonTextColor)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: theme.buttonHeight)
|
|
.background(theme.accentColor)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.padding(theme.verticalPadding)
|
|
.accessibilityLabel("Save your cookie preferences")
|
|
}
|
|
}
|
|
|
|
// MARK: - Category Row
|
|
|
|
private func categoryRow(
|
|
name: String,
|
|
description: String,
|
|
isOn: Binding<Bool>,
|
|
isToggleable: Bool
|
|
) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(name)
|
|
.font(theme.bodyFont.weight(.semibold))
|
|
.foregroundColor(theme.textColor)
|
|
Text(description)
|
|
.font(theme.captionFont)
|
|
.foregroundColor(theme.secondaryTextColor)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer()
|
|
Toggle("", isOn: isOn)
|
|
.labelsHidden()
|
|
.toggleStyle(SwitchToggleStyle(tint: theme.accentColor))
|
|
.disabled(!isToggleable)
|
|
.accessibilityLabel(isToggleable ? "Toggle \(name)" : "\(name) always active")
|
|
}
|
|
.padding(.horizontal, theme.horizontalPadding)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
// MARK: - Buttons
|
|
|
|
private var acceptButton: some View {
|
|
Button(action: {
|
|
Task { await onAcceptAll() }
|
|
}) {
|
|
Text(theme.acceptButtonText)
|
|
.font(theme.buttonFont)
|
|
.foregroundColor(theme.acceptButtonTextColor)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: theme.buttonHeight)
|
|
.background(theme.acceptButtonBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.accessibilityLabel("Accept all cookies")
|
|
}
|
|
|
|
private var rejectButton: some View {
|
|
Button(action: {
|
|
Task { await onRejectAll() }
|
|
}) {
|
|
Text(theme.rejectButtonText)
|
|
.font(theme.buttonFont)
|
|
.foregroundColor(theme.rejectButtonTextColor)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: theme.buttonHeight)
|
|
.background(theme.rejectButtonBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.accessibilityLabel("Reject all non-essential cookies")
|
|
}
|
|
|
|
private var manageButton: some View {
|
|
Button(action: { showingManage = true }) {
|
|
Text(theme.manageButtonText)
|
|
.font(theme.buttonFont)
|
|
.foregroundColor(theme.accentColor)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: theme.buttonHeight)
|
|
.background(theme.accentColor.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.accessibilityLabel("Manage your cookie preferences")
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#if DEBUG
|
|
#Preview("Main Banner") {
|
|
VStack {
|
|
Spacer()
|
|
ConsentBannerView(
|
|
theme: .defaultTheme,
|
|
onAcceptAll: {},
|
|
onRejectAll: {},
|
|
onSave: { _ in }
|
|
)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
#endif
|
|
#endif
|