Files
consentos/sdks/ios/ConsentOS/Sources/ConsentOSUI/ConsentBannerView.swift
James Cottrill fbf26453f2 feat: initial public release
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.
2026-04-14 09:18:18 +00:00

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