
Generators
- 378 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
generators is an Apple Claude Code skill with 53 Swift code generators that scaffold logging, StoreKit paywalls, widgets, CI/CD, and auth flows for developers accelerating iOS, macOS, and watchOS apps.
About
generators is the rshankras Apple code-generator skill that produces production-ready Swift for iOS and macOS apps instead of advisory reviews alone. It includes 53 generator modules—from logging-setup and analytics-setup through paywall-generator, widget-generator, live-activity-generator, and ci-cd-setup with GitHub Actions and fastlane lanes. Each generator reads existing project structure, detects Swift version, deployment targets, and architecture patterns like MVVM or TCA, then emits protocol-based services swappable between TelemetryDeck, Firebase, Sentry, and NoOp providers. Developers reach for generators when adding StoreKit 2 subscriptions, Sign in with Apple auth, SwiftData persistence, push notifications, deep linking, accessibility infrastructure, or App Store screenshot automation. Output always lists created file paths, integration steps, required entitlements, and testing instructions tailored to the detected platform and distribution channel.
- Boilerplate and template scaffolding
- Repetitive Swift pattern automation
- Multi-target Apple project setup
- Faster feature module generation
- Consistent project structure output
Generators by the numbers
- 378 all-time installs (skills.sh)
- +20 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #460 of 2,715 Automation & Workflows 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 generatorsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 378 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
How do you scaffold common Swift iOS app modules?
Scaffold Apple platform boilerplate, project files, and repetitive Swift patterns using generator workflows to accelerate iOS, macOS, and watchOS development.
Who is it for?
iOS and macOS developers adding standard app infrastructure—auth, paywalls, analytics, widgets, CI/CD—who want context-aware Swift codegen instead of copy-paste templates.
Skip if: Android or cross-platform Flutter teams, or Apple projects that only need App Store review guidelines without generating Swift infrastructure code.
When should I use this skill?
A developer asks to add, set up, or generate logging, analytics, onboarding, StoreKit paywalls, widgets, push notifications, or CI/CD for a Swift iOS or macOS project.
What you get
Production-ready Swift source files, protocol-based service layers, entitlements list, integration steps, and platform-specific testing instructions.
- Swift source files
- Integration instructions
- Entitlements and capability checklist
By the numbers
- Documents 53 production-ready Swift code generator modules
- Supports swappable providers including TelemetryDeck, Firebase, Sentry, and NoOp
- ci-cd-setup covers GitHub Actions, Xcode Cloud scripts, and fastlane lanes
Files
Accessibility Generator
Generate accessibility infrastructure for VoiceOver, Dynamic Type, and accessibility features.
When This Skill Activates
- User wants to improve app accessibility
- User mentions VoiceOver, Dynamic Type, or accessibility
- User needs to add accessibility labels and hints
- User wants to audit accessibility compliance
Pre-Generation Checks
# Check existing accessibility usage
grep -r "accessibilityLabel\|accessibilityHint\|AccessibilityFocused" --include="*.swift" | head -5Key Features
Accessibility Labels
Image(systemName: "heart.fill")
.accessibilityLabel("Favorite")
.accessibilityHint("Double tap to remove from favorites")Dynamic Type Support
Text("Title")
.font(.title) // Scales automatically
.dynamicTypeSize(...DynamicTypeSize.accessibility3) // Limit max sizeReduce Motion
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? nil : .spring()) {
// Animation
}VoiceOver Groups
VStack {
Text("Item Name")
Text("$9.99")
}
.accessibilityElement(children: .combine)Generated Files
Sources/Accessibility/
├── AccessibilityModifiers.swift # Custom view modifiers
├── AccessibilityHelpers.swift # Label builders
└── AccessibilityStrings.swift # Localized labelsAudit Checklist
- [ ] All interactive elements have labels
- [ ] Images have descriptions or are hidden decoratively
- [ ] Color is not the only indicator
- [ ] Touch targets are at least 44×44 points
- [ ] Dynamic Type is supported
- [ ] Reduce Motion is respected
- [ ] VoiceOver order is logical
References
Accessibility Patterns
Best practices for building accessible iOS/macOS apps.
VoiceOver
Labels and Hints
Button(action: { deleteItem() }) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
.accessibilityHint("Removes this item permanently")Grouping Elements
// Combine related elements into one
HStack {
Image(systemName: "star.fill")
Text("4.5 rating")
}
.accessibilityElement(children: .combine)
// Custom combined label
VStack {
Text(item.name)
Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)")Custom Actions
struct ItemRow: View {
let item: Item
var body: some View {
HStack {
Text(item.name)
Spacer()
}
.accessibilityElement(children: .combine)
.accessibilityActions {
Button("Edit") { editItem() }
Button("Delete") { deleteItem() }
Button("Share") { shareItem() }
}
}
}Traits
Text("Welcome")
.accessibilityAddTraits(.isHeader)
Button("Submit") { }
.accessibilityAddTraits(.startsMediaSession)
Text("Status: Active")
.accessibilityAddTraits(.updatesFrequently)Focus Management
struct FormView: View {
@AccessibilityFocusState private var focusedField: Field?
enum Field: Hashable {
case name, email, submit
}
var body: some View {
VStack {
TextField("Name", text: $name)
.accessibilityFocused($focusedField, equals: .name)
TextField("Email", text: $email)
.accessibilityFocused($focusedField, equals: .email)
Button("Submit") { submit() }
.accessibilityFocused($focusedField, equals: .submit)
}
.onAppear {
focusedField = .name
}
}
}Dynamic Type
Automatic Scaling
// Prefer semantic fonts - they scale automatically
Text("Title").font(.title)
Text("Body").font(.body)
Text("Caption").font(.caption)
// Custom fonts with scaling
Text("Custom")
.font(.custom("Helvetica", size: 16, relativeTo: .body))Limiting Scale
Text("Fixed Range")
.dynamicTypeSize(.small ... .xxxLarge)
Text("No Accessibility Sizes")
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)Adjusting Layouts
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
if typeSize >= .accessibility1 {
// Vertical layout for large text
VStack(alignment: .leading) {
label
value
}
} else {
// Horizontal layout for normal text
HStack {
label
Spacer()
value
}
}
}ScaledMetric
@ScaledMetric(relativeTo: .body) private var iconSize = 24.0
@ScaledMetric private var spacing = 8.0
Image(systemName: "star")
.frame(width: iconSize, height: iconSize)
.padding(spacing)Motion and Animation
Reduce Motion
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func toggleExpanded() {
if reduceMotion {
isExpanded.toggle() // Instant
} else {
withAnimation(.spring()) {
isExpanded.toggle()
}
}
}Safe Animations
extension Animation {
static var accessibleSpring: Animation {
@Environment(\.accessibilityReduceMotion) var reduceMotion
return reduceMotion ? .none : .spring()
}
}
// View modifier
struct ReducedMotionModifier: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let animation: Animation
func body(content: Content) -> some View {
content.animation(reduceMotion ? nil : animation, value: UUID())
}
}Color and Contrast
Reduce Transparency
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
var backgroundMaterial: some ShapeStyle {
reduceTransparency ? Color.systemBackground : Material.regular
}High Contrast Colors
@Environment(\.colorSchemeContrast) private var contrast
var textColor: Color {
contrast == .increased ? .primary : .secondary
}Color Blind Support
// Don't rely on color alone
HStack {
Circle()
.fill(status.color)
.frame(width: 8, height: 8)
Text(status.label) // Always include text
}
// Use patterns or shapes
if isError {
Image(systemName: "exclamationmark.triangle") // Shape indicates error
.foregroundStyle(.red)
}Accessibility Modifiers
Custom Modifier
extension View {
func accessibleCard(title: String, description: String) -> some View {
self
.accessibilityElement(children: .combine)
.accessibilityLabel(title)
.accessibilityHint(description)
.accessibilityAddTraits(.isButton)
}
func accessibleImage(_ description: String) -> some View {
self
.accessibilityLabel(description)
.accessibilityAddTraits(.isImage)
}
func accessibleDecorative() -> some View {
self.accessibilityHidden(true)
}
}Usage
CardView(item: item)
.accessibleCard(
title: item.name,
description: "Double tap to view details"
)
Image("hero")
.accessibleImage("Sunset over mountains")
Image("decorative-line")
.accessibleDecorative()Testing Accessibility
Accessibility Inspector
1. Xcode > Open Developer Tool > Accessibility Inspector 2. Target your simulator/device 3. Navigate through UI elements 4. Check labels, hints, traits
Unit Testing
import XCTest
@testable import YourApp
final class AccessibilityTests: XCTestCase {
func testButtonHasLabel() {
let button = MyButton()
let view = button.body
// Use accessibility audit APIs
XCTAssertNotNil(view.accessibilityLabel)
}
}UI Testing
func testVoiceOverNavigation() {
let app = XCUIApplication()
app.launch()
// Check element exists and is accessible
let button = app.buttons["Add Item"]
XCTAssertTrue(button.exists)
XCTAssertTrue(button.isHittable)
// Check accessibility label
XCTAssertEqual(button.label, "Add Item")
}Localized Accessibility
enum A11y {
static let addButton = String(localized: "accessibility.add_button",
defaultValue: "Add new item")
static let deleteHint = String(localized: "accessibility.delete_hint",
defaultValue: "Double tap to delete")
static func itemCount(_ count: Int) -> String {
String(localized: "accessibility.item_count \(count)",
defaultValue: "\(count) items")
}
}
// Usage
Button(action: { }) {
Image(systemName: "plus")
}
.accessibilityLabel(A11y.addButton)Checklist
Visual
- [ ] Text uses semantic fonts (
.body,.title, etc.) - [ ] Custom fonts use
relativeTo:for scaling - [ ] Minimum touch target 44×44 points
- [ ] Color is not the only indicator
- [ ] Sufficient color contrast (4.5:1 for text)
VoiceOver
- [ ] All interactive elements have labels
- [ ] Decorative images are hidden
- [ ] Meaningful images have descriptions
- [ ] Custom controls have appropriate traits
- [ ] Related content is grouped
Motion
- [ ] Animations respect Reduce Motion
- [ ] No auto-playing videos without control
- [ ] Flashing content is avoided
Interaction
- [ ] Full keyboard navigation support
- [ ] Focus order is logical
- [ ] Error states are announced
Account Deletion Code Templates
Production-ready Swift templates for Apple-compliant account deletion. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
AccountDeletionManager.swift
import Foundation
import SwiftUI
/// Orchestrates the complete account deletion lifecycle.
///
/// Manages state transitions from initiation through confirmation,
/// optional data export, grace period scheduling, and final execution.
///
/// Usage:
/// ```swift
/// @State private var deletionManager = AccountDeletionManager()
///
/// Button("Delete Account") {
/// Task { await deletionManager.initiateDeletion() }
/// }
/// ```
@Observable
final class AccountDeletionManager {
private(set) var deletionState: DeletionState = .none
private(set) var scheduledDeletionDate: Date?
private(set) var isProcessing = false
private let serverClient: AccountDeletionClient?
private let keychainCleanup: KeychainCleanup
private let gracePeriodKey = "AccountDeletion.scheduledDate"
enum DeletionState: Sendable, Equatable {
case none
case confirming
case exporting
case scheduled(Date)
case deleting
case completed
case failed(String)
static func == (lhs: DeletionState, rhs: DeletionState) -> Bool {
switch (lhs, rhs) {
case (.none, .none), (.confirming, .confirming),
(.exporting, .exporting), (.deleting, .deleting),
(.completed, .completed):
return true
case (.scheduled(let a), .scheduled(let b)):
return a == b
case (.failed(let a), .failed(let b)):
return a == b
default:
return false
}
}
}
init(
serverClient: AccountDeletionClient? = nil,
keychainCleanup: KeychainCleanup = KeychainCleanup()
) {
self.serverClient = serverClient
self.keychainCleanup = keychainCleanup
// Restore any pending scheduled deletion
if let scheduledDate = UserDefaults.standard.object(forKey: gracePeriodKey) as? Date {
self.scheduledDeletionDate = scheduledDate
self.deletionState = .scheduled(scheduledDate)
}
}
/// Begin the deletion flow — transitions to confirming state.
func initiateDeletion() async {
deletionState = .confirming
}
/// Re-authenticate the user before proceeding with deletion.
///
/// Uses LocalAuthentication for biometric or falls back to password.
func confirmWithReauthentication() async throws {
isProcessing = true
defer { isProcessing = false }
// Biometric authentication
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
throw AccountDeletionError.authenticationUnavailable
}
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Confirm your identity to delete your account"
)
guard success else {
throw AccountDeletionError.authenticationFailed
}
}
/// Export all user data before deletion.
func exportUserData() async throws -> URL {
deletionState = .exporting
let exportService = DataExportService()
return try await exportService.exportAllUserData()
}
/// Schedule deletion after a grace period instead of immediate execution.
func scheduleDeletion(gracePeriodDays: Int) async throws {
isProcessing = true
defer { isProcessing = false }
let deletionDate = Calendar.current.date(
byAdding: .day,
value: gracePeriodDays,
to: Date()
)!
// Notify server of scheduled deletion
if let serverClient {
try await serverClient.scheduleDeletion(date: deletionDate)
}
// Persist locally
UserDefaults.standard.set(deletionDate, forKey: gracePeriodKey)
scheduledDeletionDate = deletionDate
deletionState = .scheduled(deletionDate)
}
/// Cancel a previously scheduled deletion.
func cancelScheduledDeletion() async throws {
isProcessing = true
defer { isProcessing = false }
// Notify server of cancellation
if let serverClient {
try await serverClient.cancelScheduledDeletion()
}
// Clear local state
UserDefaults.standard.removeObject(forKey: gracePeriodKey)
scheduledDeletionDate = nil
deletionState = .none
}
/// Execute the account deletion — cleans up all local and remote data.
///
/// This is the final, irreversible step. Call only after confirmation.
func executeDeletion() async throws {
isProcessing = true
deletionState = .deleting
do {
// 1. Server-side deletion (if configured)
if let serverClient {
try await serverClient.deleteAccount()
}
// 2. Revoke Sign in with Apple token (if applicable)
await revokeSignInWithAppleTokenIfNeeded()
// 3. Keychain cleanup
try keychainCleanup.removeAllItems()
// 4. UserDefaults cleanup
cleanUserDefaults()
// 5. File system cleanup
cleanFileSystem()
// 6. Clear scheduled deletion
UserDefaults.standard.removeObject(forKey: gracePeriodKey)
scheduledDeletionDate = nil
deletionState = .completed
isProcessing = false
} catch {
deletionState = .failed(error.localizedDescription)
isProcessing = false
throw error
}
}
/// Check for pending scheduled deletions on app launch.
///
/// Call this from your App's `.task` modifier.
func checkPendingDeletion() async {
guard let scheduledDate = scheduledDeletionDate else { return }
if Date() >= scheduledDate {
// Grace period expired — execute deletion
try? await executeDeletion()
}
}
// MARK: - Private Cleanup Methods
private func revokeSignInWithAppleTokenIfNeeded() async {
// Check if user signed in with Apple
guard let refreshToken = retrieveAppleRefreshToken() else { return }
let revocation = SignInWithAppleRevocation()
try? await revocation.revokeToken(
refreshToken: refreshToken,
clientID: Bundle.main.bundleIdentifier ?? "",
clientSecret: "" // Generate via server
)
}
private func retrieveAppleRefreshToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "apple_refresh_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func cleanUserDefaults() {
guard let bundleID = Bundle.main.bundleIdentifier else { return }
UserDefaults.standard.removePersistentDomain(forName: bundleID)
UserDefaults.standard.synchronize()
// Also clean any app group defaults
// UserDefaults(suiteName: "group.com.yourapp")?.removePersistentDomain(forName: "group.com.yourapp")
}
private func cleanFileSystem() {
let fileManager = FileManager.default
// Documents directory
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
// Caches directory
if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: cachesURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
// Application Support directory
if let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: appSupportURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
}
}
// MARK: - Supporting Types
/// Protocol for server-side account deletion requests.
protocol AccountDeletionClient: Sendable {
func deleteAccount() async throws
func scheduleDeletion(date: Date) async throws
func cancelScheduledDeletion() async throws
}
/// Errors specific to account deletion.
enum AccountDeletionError: Error, LocalizedError {
case authenticationUnavailable
case authenticationFailed
case serverDeletionFailed(String)
case keychainCleanupFailed
case alreadyScheduled
var errorDescription: String? {
switch self {
case .authenticationUnavailable:
return "Authentication is not available on this device."
case .authenticationFailed:
return "Authentication failed. Please try again."
case .serverDeletionFailed(let reason):
return "Server deletion failed: \(reason)"
case .keychainCleanupFailed:
return "Failed to clean up stored credentials."
case .alreadyScheduled:
return "Account deletion is already scheduled."
}
}
}
// Required import for LocalAuthentication
import LocalAuthentication
import SecurityDeletionConfirmationView.swift
import SwiftUI
/// Multi-step account deletion confirmation flow.
///
/// Steps: Explain Consequences -> Optional Data Export -> Re-authenticate -> Confirm
///
/// Usage:
/// ```swift
/// .sheet(isPresented: $showDeletion) {
/// DeletionConfirmationView()
/// }
/// ```
struct DeletionConfirmationView: View {
@Environment(AccountDeletionManager.self) private var deletionManager
@Environment(\.dismiss) private var dismiss
@State private var currentStep: DeletionStep = .consequences
@State private var confirmationText = ""
@State private var includeDataExport = false
@State private var exportURL: URL?
@State private var errorMessage: String?
@State private var showError = false
enum DeletionStep: Int, CaseIterable {
case consequences
case dataExport
case reauthenticate
case finalConfirm
}
var body: some View {
NavigationStack {
VStack {
// Progress indicator
ProgressIndicatorView(
currentStep: currentStep.rawValue,
totalSteps: DeletionStep.allCases.count
)
.padding(.top)
// Step content
switch currentStep {
case .consequences:
ConsequencesStepView(
includeDataExport: $includeDataExport,
onContinue: { advanceStep() }
)
case .dataExport:
DataExportStepView(
exportURL: $exportURL,
onExport: { await exportData() },
onSkip: { advanceStep() }
)
case .reauthenticate:
ReauthenticateStepView(
onAuthenticate: { await reauthenticate() }
)
case .finalConfirm:
FinalConfirmStepView(
confirmationText: $confirmationText,
isProcessing: deletionManager.isProcessing,
onDelete: { await performDeletion() }
)
}
}
.navigationTitle("Delete Account")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.disabled(deletionManager.isProcessing)
}
}
.alert("Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(errorMessage ?? "An unexpected error occurred.")
}
}
}
// MARK: - Step Navigation
private func advanceStep() {
withAnimation {
switch currentStep {
case .consequences:
currentStep = includeDataExport ? .dataExport : .reauthenticate
case .dataExport:
currentStep = .reauthenticate
case .reauthenticate:
currentStep = .finalConfirm
case .finalConfirm:
break
}
}
}
// MARK: - Actions
private func exportData() async {
do {
exportURL = try await deletionManager.exportUserData()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func reauthenticate() async {
do {
try await deletionManager.confirmWithReauthentication()
advanceStep()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func performDeletion() async {
do {
try await deletionManager.executeDeletion()
dismiss()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Step Views
private struct ConsequencesStepView: View {
@Binding var includeDataExport: Bool
let onContinue: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
.frame(maxWidth: .infinity)
Text("What happens when you delete your account")
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
ConsequenceRow(
icon: "person.slash",
text: "Your account and profile will be permanently removed"
)
ConsequenceRow(
icon: "doc.text.fill",
text: "All your data, content, and history will be deleted"
)
ConsequenceRow(
icon: "key.fill",
text: "All saved credentials and tokens will be erased"
)
ConsequenceRow(
icon: "arrow.counterclockwise",
text: "This action cannot be undone"
)
}
Divider()
// Subscription warning
VStack(alignment: .leading, spacing: 8) {
Label("Active Subscriptions", systemImage: "creditcard.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text("Deleting your account does not cancel active subscriptions. Please cancel any subscriptions in Settings > Subscriptions before proceeding.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
Toggle("Export my data before deletion", isOn: $includeDataExport)
.padding(.top, 8)
Button(action: onContinue) {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
.padding()
}
}
}
private struct ConsequenceRow: View {
let icon: String
let text: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.red)
.frame(width: 24)
Text(text)
.font(.subheadline)
}
}
}
private struct DataExportStepView: View {
@Binding var exportURL: URL?
let onExport: () async -> Void
let onSkip: () -> Void
@State private var isExporting = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "square.and.arrow.down.fill")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Export Your Data")
.font(.title2.weight(.semibold))
Text("Download a copy of all your data before deleting your account.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let exportURL {
ShareLink(item: exportURL) {
Label("Share Export File", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal)
Button("Continue to Deletion") {
onSkip()
}
.buttonStyle(.bordered)
.controlSize(.large)
} else {
Button {
isExporting = true
Task {
await onExport()
isExporting = false
}
} label: {
if isExporting {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Export My Data", systemImage: "arrow.down.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isExporting)
.padding(.horizontal)
Button("Skip Export") {
onSkip()
}
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
private struct ReauthenticateStepView: View {
let onAuthenticate: () async -> Void
@State private var isAuthenticating = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "faceid")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Verify Your Identity")
.font(.title2.weight(.semibold))
Text("For your security, please verify your identity before deleting your account.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button {
isAuthenticating = true
Task {
await onAuthenticate()
isAuthenticating = false
}
} label: {
if isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Verify Identity", systemImage: "lock.shield.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isAuthenticating)
.padding(.horizontal)
Spacer()
}
}
}
private struct FinalConfirmStepView: View {
@Binding var confirmationText: String
let isProcessing: Bool
let onDelete: () async -> Void
private let requiredText = "DELETE"
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "trash.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
Text("Final Confirmation")
.font(.title2.weight(.semibold))
Text("Type **DELETE** to confirm account deletion.")
.font(.subheadline)
.foregroundStyle(.secondary)
TextField("Type DELETE", text: $confirmationText)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.center)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.characters)
#endif
.padding(.horizontal, 40)
Button {
Task { await onDelete() }
} label: {
if isProcessing {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Delete My Account Permanently")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
.disabled(confirmationText != requiredText || isProcessing)
.padding(.horizontal)
Spacer()
}
}
}
// MARK: - Progress Indicator
private struct ProgressIndicatorView: View {
let currentStep: Int
let totalSteps: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0..<totalSteps, id: \.self) { step in
Capsule()
.fill(step <= currentStep ? Color.red : Color.secondary.opacity(0.3))
.frame(height: 4)
}
}
.padding(.horizontal)
}
}DataExportService.swift
import Foundation
import SwiftUI
/// Collects all user data and packages it into a JSON archive for download.
///
/// Gathers data from SwiftData/CoreData, UserDefaults, and the file system,
/// then produces a JSON file that can be shared via ShareLink.
///
/// Usage:
/// ```swift
/// let exportService = DataExportService()
/// let archiveURL = try await exportService.exportAllUserData()
/// // Present ShareLink with archiveURL
/// ```
@Observable
final class DataExportService {
private(set) var isExporting = false
private(set) var exportProgress: Double = 0
/// Export all user data to a JSON file in the temporary directory.
///
/// - Returns: URL to the exported JSON file.
func exportAllUserData() async throws -> URL {
isExporting = true
exportProgress = 0
defer { isExporting = false }
var exportData: [String: Any] = [:]
// 1. Export UserDefaults
exportProgress = 0.2
exportData["userDefaults"] = exportUserDefaults()
// 2. Export documents directory file list
exportProgress = 0.4
exportData["documents"] = exportDocumentsManifest()
// 3. Export app-specific data
// TODO: Add your SwiftData/CoreData model exports here
// Example:
// exportData["posts"] = try await exportPosts()
// exportData["comments"] = try await exportComments()
exportProgress = 0.7
// 4. Export metadata
exportData["exportMetadata"] = [
"exportDate": ISO8601DateFormatter().string(from: Date()),
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown",
"bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown"
]
exportProgress = 0.9
// 5. Serialize and write to temp file
let jsonData = try JSONSerialization.data(
withJSONObject: exportData,
options: [.prettyPrinted, .sortedKeys]
)
let fileName = "account-data-export-\(formattedDate()).json"
let exportURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
try jsonData.write(to: exportURL)
exportProgress = 1.0
return exportURL
}
// MARK: - Private
private func exportUserDefaults() -> [String: Any] {
guard let bundleID = Bundle.main.bundleIdentifier else { return [:] }
return UserDefaults.standard.persistentDomain(forName: bundleID) ?? [:]
}
private func exportDocumentsManifest() -> [[String: String]] {
let fileManager = FileManager.default
guard let documentsURL = fileManager.urls(
for: .documentDirectory,
in: .userDomainMask
).first else { return [] }
let files = (try? fileManager.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
options: .skipsHiddenFiles
)) ?? []
return files.map { url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
let created = (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate
return [
"name": url.lastPathComponent,
"size": "\(size) bytes",
"created": created.map { ISO8601DateFormatter().string(from: $0) } ?? "unknown"
]
}
}
private func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: Date())
}
}DeletionGracePeriodView.swift
import SwiftUI
/// Displays the grace period countdown and option to cancel scheduled deletion.
///
/// Show this view when the user has a pending account deletion.
///
/// Usage:
/// ```swift
/// if case .scheduled(let date) = deletionManager.deletionState {
/// DeletionGracePeriodView(scheduledDate: date)
/// }
/// ```
struct DeletionGracePeriodView: View {
@Environment(AccountDeletionManager.self) private var deletionManager
let scheduledDate: Date
@State private var errorMessage: String?
@State private var showError = false
var body: some View {
VStack(spacing: 24) {
// Warning icon
Image(systemName: "clock.badge.exclamationmark.fill")
.font(.system(size: 56))
.foregroundStyle(.orange)
.symbolRenderingMode(.multicolor)
// Countdown
Text("Account Deletion Scheduled")
.font(.title2.weight(.semibold))
Text("Your account will be permanently deleted on:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(scheduledDate, style: .date)
.font(.title3.weight(.medium))
.foregroundStyle(.red)
// Time remaining
TimeRemainingView(targetDate: scheduledDate)
Divider()
.padding(.horizontal)
// What happens
VStack(alignment: .leading, spacing: 12) {
Text("When the period expires:")
.font(.subheadline.weight(.semibold))
Label("All account data will be permanently deleted", systemImage: "trash")
.font(.subheadline)
.foregroundStyle(.secondary)
Label("All stored credentials will be removed", systemImage: "key.slash")
.font(.subheadline)
.foregroundStyle(.secondary)
Label("This action cannot be reversed", systemImage: "arrow.counterclockwise")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
Spacer()
// Cancel button
Button {
Task {
do {
try await deletionManager.cancelScheduledDeletion()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
} label: {
Text("Cancel Account Deletion")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(deletionManager.isProcessing)
.padding(.horizontal)
.padding(.bottom)
}
.alert("Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(errorMessage ?? "Failed to cancel deletion.")
}
}
}
/// Displays a live countdown to the target date.
private struct TimeRemainingView: View {
let targetDate: Date
@State private var timeRemaining: String = ""
var body: some View {
Text(timeRemaining)
.font(.headline)
.foregroundStyle(.orange)
.monospacedDigit()
.onAppear { updateTimeRemaining() }
.task {
// Update every minute
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
updateTimeRemaining()
}
}
}
private func updateTimeRemaining() {
let components = Calendar.current.dateComponents(
[.day, .hour, .minute],
from: Date(),
to: targetDate
)
let days = components.day ?? 0
let hours = components.hour ?? 0
let minutes = components.minute ?? 0
if days > 0 {
timeRemaining = "\(days) day\(days == 1 ? "" : "s"), \(hours) hour\(hours == 1 ? "" : "s") remaining"
} else if hours > 0 {
timeRemaining = "\(hours) hour\(hours == 1 ? "" : "s"), \(minutes) minute\(minutes == 1 ? "" : "s") remaining"
} else {
timeRemaining = "\(max(0, minutes)) minute\(minutes == 1 ? "" : "s") remaining"
}
}
}KeychainCleanup.swift
import Foundation
import Security
/// Utility to remove all app Keychain items during account deletion.
///
/// Handles SecItemDelete for all item classes:
/// - Generic passwords (kSecClassGenericPassword)
/// - Internet passwords (kSecClassInternetPassword)
/// - Certificates (kSecClassCertificate)
/// - Keys (kSecClassKey)
/// - Identities (kSecClassIdentity)
///
/// Usage:
/// ```swift
/// let cleanup = KeychainCleanup()
/// try cleanup.removeAllItems()
/// ```
struct KeychainCleanup: Sendable {
/// All Keychain item classes that need to be cleaned.
private static let itemClasses: [CFString] = [
kSecClassGenericPassword,
kSecClassInternetPassword,
kSecClassCertificate,
kSecClassKey,
kSecClassIdentity
]
/// Remove all Keychain items stored by this app.
///
/// Iterates through all item classes and deletes matching entries.
/// - Throws: `AccountDeletionError.keychainCleanupFailed` if any deletion fails
/// with an error other than `errSecItemNotFound`.
func removeAllItems() throws {
var errors: [OSStatus] = []
for itemClass in Self.itemClasses {
let query: [String: Any] = [
kSecClass as String: itemClass
]
let status = SecItemDelete(query as CFDictionary)
// errSecItemNotFound is fine — means nothing to delete
if status != errSecSuccess && status != errSecItemNotFound {
errors.append(status)
}
}
if !errors.isEmpty {
throw AccountDeletionError.keychainCleanupFailed
}
}
/// Remove a specific Keychain item by account name.
///
/// - Parameters:
/// - account: The account identifier (kSecAttrAccount value).
/// - itemClass: The Keychain item class. Defaults to generic password.
func removeItem(account: String, itemClass: CFString = kSecClassGenericPassword) throws {
let query: [String: Any] = [
kSecClass as String: itemClass,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw AccountDeletionError.keychainCleanupFailed
}
}
}SignInWithAppleRevocation.swift
import Foundation
import AuthenticationServices
/// Revokes a Sign in with Apple refresh token via Apple's REST API.
///
/// Required for compliance when deleting accounts that used Sign in with Apple.
/// Apple mandates that apps revoke tokens when a user deletes their account.
///
/// Reference: https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens
///
/// Usage:
/// ```swift
/// let revocation = SignInWithAppleRevocation()
/// try await revocation.revokeToken(
/// refreshToken: storedRefreshToken,
/// clientID: Bundle.main.bundleIdentifier!,
/// clientSecret: generatedClientSecret
/// )
/// ```
struct SignInWithAppleRevocation: Sendable {
private let revokeURL = URL(string: "https://appleid.apple.com/auth/revoke")!
/// Revoke a Sign in with Apple token.
///
/// - Parameters:
/// - refreshToken: The refresh token obtained during Sign in with Apple.
/// - clientID: Your app's bundle identifier (client_id).
/// - clientSecret: A JWT client secret generated server-side.
///
/// - Note: The client secret must be a JWT signed with your Sign in with Apple
/// private key. Generate this on your server, not in the client app.
func revokeToken(
refreshToken: String,
clientID: String,
clientSecret: String
) async throws {
var request = URLRequest(url: revokeURL)
request.httpMethod = "POST"
request.setValue(
"application/x-www-form-urlencoded",
forHTTPHeaderField: "Content-Type"
)
let parameters = [
"client_id": clientID,
"client_secret": clientSecret,
"token": refreshToken,
"token_type_hint": "refresh_token"
]
request.httpBody = parameters
.map { "\($0.key)=\($0.value)" }
.joined(separator: "&")
.data(using: .utf8)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SignInWithAppleRevocationError.invalidResponse
}
// 200 = success, 400 = invalid token (already revoked, which is fine)
guard httpResponse.statusCode == 200 || httpResponse.statusCode == 400 else {
throw SignInWithAppleRevocationError.revocationFailed(
statusCode: httpResponse.statusCode
)
}
}
/// Check if the current user's Apple ID credential is still valid.
///
/// Call this to determine if Sign in with Apple revocation is needed.
func checkCredentialState(userID: String) async -> ASAuthorizationAppleIDProvider.CredentialState {
await withCheckedContinuation { continuation in
ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, _ in
continuation.resume(returning: state)
}
}
}
}
/// Errors specific to Sign in with Apple token revocation.
enum SignInWithAppleRevocationError: Error, LocalizedError {
case invalidResponse
case revocationFailed(statusCode: Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from Apple's revocation endpoint."
case .revocationFailed(let statusCode):
return "Token revocation failed with status code \(statusCode)."
}
}
}Analytics Patterns and Best Practices
Protocol-Based Architecture
Why Protocols?
1. Testability - Use NoOpAnalytics in tests 2. Flexibility - Swap providers without code changes 3. Privacy - Easy to disable analytics per user preference 4. Gradual Migration - Add new provider, switch when ready
The Core Protocol
protocol AnalyticsService: Sendable {
func configure()
func track(_ event: AnalyticsEvent)
func track(_ event: AnalyticsEvent, properties: [String: String])
func setUserProperty(_ key: String, value: String)
func setUserID(_ id: String?)
func reset()
}Default Implementations
Provide defaults for optional methods:
extension AnalyticsService {
func track(_ event: AnalyticsEvent) {
track(event, properties: [:])
}
func setUserID(_ id: String?) {
// Optional - not all providers support this
}
func reset() {
// Called on logout - reset user properties
}
}Event Design
Enum-Based Events
Use enums for type-safe event tracking:
enum AnalyticsEvent: Sendable {
// Lifecycle
case appLaunched
case appBackgrounded
case appForegrounded
// Screens
case screenViewed(name: String)
// User Actions
case buttonTapped(name: String)
case featureUsed(name: String)
// Errors
case errorOccurred(domain: String, code: Int)
// Custom
case custom(name: String, properties: [String: String])
}Event Naming Conventions
| Pattern | Example | Notes |
|---|---|---|
noun_verbed | button_tapped | Past tense for completed actions |
screen_viewed | settings_screen_viewed | For navigation |
feature_used | dark_mode_enabled | For feature adoption |
Event Properties
Add context without bloating the enum:
// Good - properties in track call
analytics.track(.buttonTapped("subscribe"), properties: [
"screen": "paywall",
"variant": "annual"
])
// Avoid - too many enum cases
case subscribeButtonTappedOnPaywallAnnual // Don't do thisProvider Implementations
TelemetryDeck (Recommended)
Privacy-friendly, no user consent required in most jurisdictions:
final class TelemetryDeckAnalytics: AnalyticsService {
private let appID: String
init(appID: String) {
self.appID = appID
}
func configure() {
TelemetryManager.initialize(with: .init(appID: appID))
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
var allProperties = properties
allProperties["event_name"] = event.name
TelemetryManager.send(event.signalName, with: allProperties)
}
}Firebase Analytics
Full-featured but requires consent in EU:
final class FirebaseAnalytics: AnalyticsService {
func configure() {
FirebaseApp.configure()
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
Analytics.logEvent(event.firebaseName, parameters: properties)
}
func setUserProperty(_ key: String, value: String) {
Analytics.setUserProperty(value, forName: key)
}
func setUserID(_ id: String?) {
Analytics.setUserID(id)
}
}NoOp Implementation
Essential for testing and privacy:
final class NoOpAnalytics: AnalyticsService {
func configure() {}
func track(_ event: AnalyticsEvent, properties: [String: String]) {}
func setUserProperty(_ key: String, value: String) {}
func setUserID(_ id: String?) {}
func reset() {}
}SwiftUI Integration
Environment Key
private struct AnalyticsServiceKey: EnvironmentKey {
static let defaultValue: AnalyticsService = NoOpAnalytics()
}
extension EnvironmentValues {
var analytics: AnalyticsService {
get { self[AnalyticsServiceKey.self] }
set { self[AnalyticsServiceKey.self] = newValue }
}
}Usage in Views
struct SettingsView: View {
@Environment(\.analytics) private var analytics
var body: some View {
List {
Button("Reset") {
analytics.track(.buttonTapped("reset_settings"))
// ... reset logic
}
}
.onAppear {
analytics.track(.screenViewed(name: "Settings"))
}
}
}View Modifier for Screen Tracking
struct AnalyticsScreenModifier: ViewModifier {
let screenName: String
@Environment(\.analytics) private var analytics
func body(content: Content) -> some View {
content.onAppear {
analytics.track(.screenViewed(name: screenName))
}
}
}
extension View {
func trackScreen(_ name: String) -> some View {
modifier(AnalyticsScreenModifier(screenName: name))
}
}
// Usage
SettingsView()
.trackScreen("Settings")Privacy Considerations
User Consent
final class ConsentAwareAnalytics: AnalyticsService {
private let wrapped: AnalyticsService
private let consentProvider: () -> Bool
init(wrapped: AnalyticsService, consentProvider: @escaping () -> Bool) {
self.wrapped = wrapped
self.consentProvider = consentProvider
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
guard consentProvider() else { return }
wrapped.track(event, properties: properties)
}
}
// Usage
let analytics = ConsentAwareAnalytics(
wrapped: FirebaseAnalytics(),
consentProvider: { UserDefaults.standard.bool(forKey: "analyticsConsent") }
)What NOT to Track
- Personal identifiable information (PII)
- Exact timestamps (use day/hour granularity)
- Precise location (use region/country)
- Content of user-generated text
- Financial details
What's Safe to Track
- Feature usage (which features are used)
- App version and OS version
- Error types (not full stack traces)
- Session duration (rounded)
- Screen flow (which screens visited)
Testing
Unit Tests
class FeatureViewModelTests: XCTestCase {
func testButtonTracksEvent() {
let mockAnalytics = MockAnalytics()
let viewModel = FeatureViewModel(analytics: mockAnalytics)
viewModel.didTapButton()
XCTAssertEqual(mockAnalytics.trackedEvents.count, 1)
XCTAssertEqual(mockAnalytics.trackedEvents.first?.name, "button_tapped")
}
}
class MockAnalytics: AnalyticsService {
var trackedEvents: [AnalyticsEvent] = []
func track(_ event: AnalyticsEvent, properties: [String: String]) {
trackedEvents.append(event)
}
}SwiftUI Previews
#Preview {
ContentView()
.environment(\.analytics, NoOpAnalytics())
}Migration Strategy
Adding Analytics to Existing App
1. Add protocol and NoOp implementation 2. Wire up Environment 3. Add tracking calls with NoOp (verify builds) 4. Add real provider 5. Switch from NoOp to real provider
Switching Providers
1. Add new provider implementation 2. Test with subset of users (feature flag) 3. Monitor for issues 4. Switch all users 5. Remove old provider (optional)
import Foundation
/// App analytics events.
///
/// Add your app-specific events here. Use past tense for completed actions.
///
/// Example usage:
/// ```swift
/// analytics.track(.screenViewed(name: "Settings"))
/// analytics.track(.buttonTapped("subscribe"))
/// analytics.track(.featureUsed("dark_mode"))
/// ```
enum AnalyticsEvent: Sendable {
// MARK: - App Lifecycle
/// App launched (cold start)
case appLaunched
/// App moved to background
case appBackgrounded
/// App returned to foreground
case appForegrounded
// MARK: - Navigation
/// Screen was viewed
case screenViewed(name: String)
// MARK: - User Actions
/// Button was tapped
case buttonTapped(name: String)
/// Feature was used/enabled
case featureUsed(name: String)
/// Search performed
case searchPerformed(query: String)
// MARK: - Errors
/// Error occurred
case errorOccurred(domain: String, code: Int)
// MARK: - Custom Events
/// Custom event with properties
case custom(name: String, properties: [String: String])
// MARK: - Add Your Events Below
// Example:
// case itemCreated(type: String)
// case itemDeleted(type: String)
// case subscriptionStarted(plan: String)
// case settingsChanged(setting: String, value: String)
}
// MARK: - Event Names
extension AnalyticsEvent {
/// The event name string used for tracking.
var name: String {
switch self {
case .appLaunched:
return "app_launched"
case .appBackgrounded:
return "app_backgrounded"
case .appForegrounded:
return "app_foregrounded"
case .screenViewed(let name):
return "screen_viewed_\(name.lowercased().replacingOccurrences(of: " ", with: "_"))"
case .buttonTapped(let name):
return "button_tapped_\(name.lowercased())"
case .featureUsed(let name):
return "feature_used_\(name.lowercased())"
case .searchPerformed:
return "search_performed"
case .errorOccurred(let domain, let code):
return "error_\(domain.lowercased())_\(code)"
case .custom(let name, _):
return name
}
}
/// Additional properties for the event.
var properties: [String: String] {
switch self {
case .screenViewed(let name):
return ["screen_name": name]
case .buttonTapped(let name):
return ["button_name": name]
case .featureUsed(let name):
return ["feature_name": name]
case .searchPerformed(let query):
// Don't track exact query for privacy - just that search was used
return ["query_length": String(query.count)]
case .errorOccurred(let domain, let code):
return ["error_domain": domain, "error_code": String(code)]
case .custom(_, let properties):
return properties
default:
return [:]
}
}
}
import Foundation
/// Protocol defining the analytics service interface.
///
/// This protocol allows swapping analytics providers without changing app code.
/// Use `NoOpAnalytics` for testing and previews.
///
/// Example:
/// ```swift
/// // In App.swift
/// let analytics: AnalyticsService = TelemetryDeckAnalytics(appID: "...")
///
/// // To swap providers, change ONE line:
/// let analytics: AnalyticsService = FirebaseAnalytics()
/// ```
protocol AnalyticsService: Sendable {
/// Configure the analytics service. Call once at app launch.
func configure()
/// Track an event.
func track(_ event: AnalyticsEvent)
/// Track an event with additional properties.
func track(_ event: AnalyticsEvent, properties: [String: String])
/// Set a user property that persists across sessions.
func setUserProperty(_ key: String, value: String)
/// Set the user ID for attribution. Pass nil to clear.
func setUserID(_ id: String?)
/// Reset all user data. Call on logout.
func reset()
}
// MARK: - Default Implementations
extension AnalyticsService {
func track(_ event: AnalyticsEvent) {
track(event, properties: [:])
}
func setUserID(_ id: String?) {
// Optional - not all providers support this
}
func reset() {
// Default no-op
}
}
// MARK: - Common User Property Keys
enum AnalyticsUserProperty {
static let appVersion = "app_version"
static let subscriptionStatus = "subscription_status"
static let theme = "theme"
// Add your custom properties here
}
import SwiftUI
/// SwiftUI Environment key for analytics service.
///
/// Usage:
/// ```swift
/// // In App.swift
/// ContentView()
/// .environment(\.analytics, TelemetryDeckAnalytics(appID: "..."))
///
/// // In any View
/// struct MyView: View {
/// @Environment(\.analytics) private var analytics
///
/// var body: some View {
/// Button("Action") {
/// analytics.track(.buttonTapped("action"))
/// }
/// }
/// }
/// ```
private struct AnalyticsServiceKey: EnvironmentKey {
static let defaultValue: AnalyticsService = NoOpAnalytics()
}
extension EnvironmentValues {
var analytics: AnalyticsService {
get { self[AnalyticsServiceKey.self] }
set { self[AnalyticsServiceKey.self] = newValue }
}
}
// MARK: - Screen Tracking Modifier
/// View modifier for automatic screen tracking.
///
/// Usage:
/// ```swift
/// SettingsView()
/// .trackScreen("Settings")
/// ```
struct AnalyticsScreenModifier: ViewModifier {
let screenName: String
@Environment(\.analytics) private var analytics
func body(content: Content) -> some View {
content.onAppear {
analytics.track(.screenViewed(name: screenName))
}
}
}
extension View {
/// Track when this screen appears.
func trackScreen(_ name: String) -> some View {
modifier(AnalyticsScreenModifier(screenName: name))
}
}
import Foundation
import FirebaseCore
import FirebaseAnalytics
/// Firebase Analytics implementation.
///
/// Firebase Analytics provides comprehensive analytics with Google's infrastructure.
/// Note: May require user consent in EU/GDPR jurisdictions.
///
/// Setup:
/// 1. Add to Package.swift:
/// .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.0.0")
/// // Add FirebaseAnalytics product
///
/// 2. Download GoogleService-Info.plist from Firebase Console
/// and add to your app target
///
/// 3. Initialize in App.swift:
/// let analytics: AnalyticsService = FirebaseAnalytics()
///
/// Usage:
/// ```swift
/// analytics.track(.screenViewed(name: "Settings"))
/// analytics.track(.buttonTapped("subscribe"))
/// ```
final class FirebaseAnalyticsService: AnalyticsService, @unchecked Sendable {
init() {}
func configure() {
// FirebaseApp.configure() should be called once at app launch
if FirebaseApp.app() == nil {
FirebaseApp.configure()
}
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
var parameters: [String: Any] = event.properties
for (key, value) in properties {
parameters[key] = value
}
Analytics.logEvent(event.firebaseName, parameters: parameters.isEmpty ? nil : parameters)
}
func setUserProperty(_ key: String, value: String) {
Analytics.setUserProperty(value, forName: key)
}
func setUserID(_ id: String?) {
Analytics.setUserID(id)
}
func reset() {
Analytics.setUserID(nil)
Analytics.resetAnalyticsData()
}
}
// MARK: - Firebase Event Names
extension AnalyticsEvent {
/// Firebase-compatible event name.
/// Firebase has specific naming requirements and reserved event names.
var firebaseName: String {
switch self {
case .appLaunched:
return "app_open" // Firebase reserved event
case .screenViewed:
return AnalyticsEventScreenView // Firebase reserved event
case .buttonTapped:
return "button_tap"
case .featureUsed:
return "feature_use"
case .searchPerformed:
return AnalyticsEventSearch // Firebase reserved event
case .errorOccurred:
return "app_error"
case .custom(let name, _):
return name
default:
return name.replacingOccurrences(of: "-", with: "_")
}
}
}
// MARK: - Consent Management
extension FirebaseAnalyticsService {
/// Set analytics collection based on user consent.
/// Call this when user grants or revokes consent.
static func setAnalyticsCollectionEnabled(_ enabled: Bool) {
Analytics.setAnalyticsCollectionEnabled(enabled)
}
}
import Foundation
/// No-operation analytics implementation.
///
/// Use for:
/// - Unit tests
/// - SwiftUI previews
/// - Users who opted out of analytics
/// - Debug builds (optional)
///
/// Example:
/// ```swift
/// // In tests
/// let analytics: AnalyticsService = NoOpAnalytics()
///
/// // In previews
/// #Preview {
/// ContentView()
/// .environment(\.analytics, NoOpAnalytics())
/// }
///
/// // For user opt-out
/// let analytics: AnalyticsService = userOptedIn ? TelemetryDeckAnalytics(...) : NoOpAnalytics()
/// ```
final class NoOpAnalytics: AnalyticsService, @unchecked Sendable {
init() {}
func configure() {
// No-op
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
// No-op
#if DEBUG
// Uncomment to see what would be tracked:
// print("[Analytics NoOp] \(event.name) - \(properties)")
#endif
}
func setUserProperty(_ key: String, value: String) {
// No-op
}
func setUserID(_ id: String?) {
// No-op
}
func reset() {
// No-op
}
}
import Foundation
import TelemetryClient
/// TelemetryDeck analytics implementation.
///
/// TelemetryDeck is a privacy-friendly analytics service that doesn't require
/// user consent in most jurisdictions (no cookies, no personal data).
///
/// Setup:
/// 1. Add to Package.swift:
/// .package(url: "https://github.com/TelemetryDeck/SwiftClient", from: "1.0.0")
///
/// 2. Get your App ID from https://dashboard.telemetrydeck.com
///
/// 3. Initialize in App.swift:
/// let analytics: AnalyticsService = TelemetryDeckAnalytics(appID: "YOUR-APP-ID")
///
/// Usage:
/// ```swift
/// analytics.track(.screenViewed(name: "Settings"))
/// analytics.track(.buttonTapped("subscribe"))
/// ```
final class TelemetryDeckAnalytics: AnalyticsService, @unchecked Sendable {
private let appID: String
/// Initialize with your TelemetryDeck App ID.
/// - Parameter appID: Your app ID from TelemetryDeck dashboard
init(appID: String) {
self.appID = appID
}
func configure() {
let config = TelemetryManagerConfiguration(appID: appID)
TelemetryManager.initialize(with: config)
}
func track(_ event: AnalyticsEvent, properties: [String: String]) {
var allProperties = event.properties
for (key, value) in properties {
allProperties[key] = value
}
TelemetryManager.send(event.name, with: allProperties)
}
func setUserProperty(_ key: String, value: String) {
// TelemetryDeck uses default user properties from the SDK
// Custom properties are sent with each signal
// Store locally if you need to include in all future events
}
func setUserID(_ id: String?) {
// TelemetryDeck generates anonymous user IDs automatically
// No need to set explicitly
}
func reset() {
// TelemetryDeck doesn't store user state that needs resetting
}
}
// MARK: - Configuration Options
extension TelemetryDeckAnalytics {
/// Create with additional configuration options.
static func configured(
appID: String,
salt: String? = nil,
testMode: Bool = false
) -> TelemetryDeckAnalytics {
let analytics = TelemetryDeckAnalytics(appID: appID)
// Configure with options if needed
var config = TelemetryManagerConfiguration(appID: appID)
if let salt = salt {
config.salt = salt
}
config.testMode = testMode
TelemetryManager.initialize(with: config)
return analytics
}
}
Announcement Banner Code Templates
Production-ready Swift templates for an in-app announcement banner system. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
Announcement.swift
import Foundation
/// Represents an in-app announcement to display as a banner.
///
/// Announcements are prioritized, scheduled, and style-aware.
/// They can trigger deep links, open URLs, or simply be dismissed.
struct Announcement: Codable, Sendable, Identifiable {
let id: String
let title: String
let message: String
let style: Style
let action: Action
let priority: Int
let startDate: Date?
let endDate: Date?
let isDismissible: Bool
let targetAudience: Audience
init(
id: String,
title: String,
message: String,
style: Style = .info,
action: Action = .dismiss,
priority: Int = 0,
startDate: Date? = nil,
endDate: Date? = nil,
isDismissible: Bool = true,
targetAudience: Audience = .all
) {
self.id = id
self.title = title
self.message = message
self.style = style
self.action = action
self.priority = priority
self.startDate = startDate
self.endDate = endDate
self.isDismissible = isDismissible
self.targetAudience = targetAudience
}
// MARK: - Style
/// Visual style that determines banner colors and icon.
enum Style: String, Codable, Sendable {
case info
case warning
case success
case promotion
}
// MARK: - Action
/// Action triggered when the user taps the banner's action button.
enum Action: Codable, Sendable {
case deepLink(String)
case url(URL)
case dismiss
// Custom Codable to handle associated values
enum CodingKeys: String, CodingKey {
case type, value
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "deepLink":
let value = try container.decode(String.self, forKey: .value)
self = .deepLink(value)
case "url":
let value = try container.decode(URL.self, forKey: .value)
self = .url(value)
default:
self = .dismiss
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .deepLink(let destination):
try container.encode("deepLink", forKey: .type)
try container.encode(destination, forKey: .value)
case .url(let url):
try container.encode("url", forKey: .type)
try container.encode(url, forKey: .value)
case .dismiss:
try container.encode("dismiss", forKey: .type)
}
}
}
// MARK: - Audience
/// Target audience for the announcement.
enum Audience: String, Codable, Sendable {
case all
case freeUsers
case proUsers
case newUsers
}
}
/// Response wrapper for remote announcement JSON.
struct AnnouncementResponse: Codable, Sendable {
let announcements: [Announcement]
}AnnouncementManager.swift
import Foundation
import SwiftUI
/// Manages announcement loading, filtering, prioritization, and dismissal tracking.
///
/// Exposes the single highest-priority active announcement to the UI.
/// Dismissed announcements are persisted across app launches.
///
/// Usage:
/// ```swift
/// let manager = AnnouncementManager(provider: RemoteAnnouncementProvider(url: configURL))
/// await manager.loadAnnouncements()
/// if let banner = manager.activeAnnouncement { ... }
/// ```
@Observable
final class AnnouncementManager {
/// The highest-priority announcement that should be displayed.
private(set) var activeAnnouncement: Announcement?
/// All currently loaded announcements (unfiltered).
private(set) var allAnnouncements: [Announcement] = []
private let provider: any AnnouncementProviding
private let dismissalStore: any DismissalStoring
private let scheduler: AnnouncementScheduler
private let audienceResolver: AudienceResolver
init(
provider: any AnnouncementProviding,
dismissalStore: any DismissalStoring = UserDefaultsDismissalStore(),
scheduler: AnnouncementScheduler = AnnouncementScheduler(),
audienceResolver: AudienceResolver = AudienceResolver()
) {
self.provider = provider
self.dismissalStore = dismissalStore
self.scheduler = scheduler
self.audienceResolver = audienceResolver
}
/// Load announcements from the provider and update the active announcement.
func loadAnnouncements() async {
do {
let announcements = try await provider.fetchAnnouncements()
allAnnouncements = announcements
updateActiveAnnouncement()
} catch {
// Silently fail — banner is non-critical UI
// Optionally log: print("Failed to load announcements: \(error)")
}
}
/// Dismiss an announcement so it won't appear again.
func dismiss(_ announcement: Announcement) {
dismissalStore.markDismissed(id: announcement.id)
updateActiveAnnouncement()
}
/// Force refresh announcements from the provider.
func refresh() async {
await loadAnnouncements()
}
/// Check if an announcement has been dismissed.
func isDismissed(_ announcement: Announcement) -> Bool {
dismissalStore.isDismissed(id: announcement.id)
}
// MARK: - Private
private func updateActiveAnnouncement() {
let now = Date()
let eligible = allAnnouncements
.filter { !dismissalStore.isDismissed(id: $0.id) }
.filter { scheduler.isActive($0, at: now) }
.filter { audienceResolver.matches($0.targetAudience) }
.sorted { $0.priority > $1.priority }
activeAnnouncement = eligible.first
}
}
// MARK: - Dismissal Store Protocol
/// Protocol for persisting dismissed announcement IDs.
protocol DismissalStoring: Sendable {
func isDismissed(id: String) -> Bool
func markDismissed(id: String)
func clearAll()
}
/// UserDefaults-backed dismissal store.
final class UserDefaultsDismissalStore: DismissalStoring, @unchecked Sendable {
private let defaults: UserDefaults
private let key = "dismissed_announcement_ids"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func isDismissed(id: String) -> Bool {
dismissedIDs.contains(id)
}
func markDismissed(id: String) {
var ids = dismissedIDs
ids.insert(id)
defaults.set(Array(ids), forKey: key)
}
func clearAll() {
defaults.removeObject(forKey: key)
}
private var dismissedIDs: Set<String> {
Set(defaults.stringArray(forKey: key) ?? [])
}
}
/// In-memory dismissal store for testing and previews.
final class InMemoryDismissalStore: DismissalStoring, @unchecked Sendable {
private var dismissedIDs: Set<String> = []
func isDismissed(id: String) -> Bool {
dismissedIDs.contains(id)
}
func markDismissed(id: String) {
dismissedIDs.insert(id)
}
func clearAll() {
dismissedIDs.removeAll()
}
}
// MARK: - Audience Resolver
/// Resolves whether the current user matches a target audience.
///
/// Customize this class to check actual user state (subscription status, install date, etc.).
final class AudienceResolver: Sendable {
func matches(_ audience: Announcement.Audience) -> Bool {
switch audience {
case .all:
return true
case .freeUsers:
// TODO: Replace with actual subscription check
return true
case .proUsers:
// TODO: Replace with actual subscription check
return false
case .newUsers:
// TODO: Replace with actual install date check
return false
}
}
}
// MARK: - Environment Key
private struct AnnouncementManagerKey: EnvironmentKey {
static let defaultValue: AnnouncementManager = AnnouncementManager(
provider: LocalAnnouncementProvider(announcements: [])
)
}
extension EnvironmentValues {
var announcementManager: AnnouncementManager {
get { self[AnnouncementManagerKey.self] }
set { self[AnnouncementManagerKey.self] = newValue }
}
}AnnouncementProvider.swift
import Foundation
/// Protocol for fetching announcements from any source.
protocol AnnouncementProviding: Sendable {
func fetchAnnouncements() async throws -> [Announcement]
}
// MARK: - Local Provider
/// Provides hardcoded announcements defined in code.
///
/// Useful for announcements that ship with app updates
/// or as fallbacks when remote config is unavailable.
///
/// Usage:
/// ```swift
/// let provider = LocalAnnouncementProvider(announcements: [
/// Announcement(id: "welcome", title: "Welcome!", message: "Thanks for downloading.", style: .info)
/// ])
/// ```
struct LocalAnnouncementProvider: AnnouncementProviding {
let announcements: [Announcement]
func fetchAnnouncements() async throws -> [Announcement] {
announcements
}
}
// MARK: - Remote Provider
/// Fetches announcements from a remote JSON endpoint with caching.
///
/// Expected JSON format:
/// ```json
/// {
/// "announcements": [
/// {
/// "id": "maintenance-2024",
/// "title": "Scheduled Maintenance",
/// "message": "We'll be down Saturday 2-4 AM EST.",
/// "style": "warning",
/// "action": { "type": "url", "value": "https://status.example.com" },
/// "priority": 100,
/// "startDate": "2024-06-14T06:00:00Z",
/// "endDate": "2024-06-15T08:00:00Z",
/// "isDismissible": false,
/// "targetAudience": "all"
/// }
/// ]
/// }
/// ```
actor RemoteAnnouncementProvider: AnnouncementProviding {
private let url: URL
private let session: URLSession
private let cacheDuration: TimeInterval
private var cachedAnnouncements: [Announcement]?
private var lastFetchDate: Date?
init(
url: URL,
session: URLSession = .shared,
cacheDuration: TimeInterval = 3600 // 1 hour default
) {
self.url = url
self.session = session
self.cacheDuration = cacheDuration
}
func fetchAnnouncements() async throws -> [Announcement] {
// Return cached data if still valid
if let cached = cachedAnnouncements,
let lastFetch = lastFetchDate,
Date().timeIntervalSince(lastFetch) < cacheDuration {
return cached
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw AnnouncementError.fetchFailed
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let announcementResponse = try decoder.decode(AnnouncementResponse.self, from: data)
cachedAnnouncements = announcementResponse.announcements
lastFetchDate = Date()
return announcementResponse.announcements
}
/// Force clear the cache so the next fetch hits the network.
func invalidateCache() {
cachedAnnouncements = nil
lastFetchDate = nil
}
}
// MARK: - Combined Provider
/// Combines multiple providers, merging announcements from all sources.
///
/// Useful for combining remote announcements with local fallbacks.
///
/// Usage:
/// ```swift
/// let provider = CombinedAnnouncementProvider(providers: [
/// RemoteAnnouncementProvider(url: configURL),
/// LocalAnnouncementProvider(announcements: localAnnouncements)
/// ])
/// ```
struct CombinedAnnouncementProvider: AnnouncementProviding {
let providers: [any AnnouncementProviding]
func fetchAnnouncements() async throws -> [Announcement] {
var allAnnouncements: [Announcement] = []
for provider in providers {
do {
let announcements = try await provider.fetchAnnouncements()
allAnnouncements.append(contentsOf: announcements)
} catch {
// Continue with other providers if one fails
continue
}
}
// Deduplicate by ID, keeping the first occurrence (remote takes priority)
var seen = Set<String>()
return allAnnouncements.filter { announcement in
guard !seen.contains(announcement.id) else { return false }
seen.insert(announcement.id)
return true
}
}
}
// MARK: - Mock Provider (Testing)
/// Mock provider for testing and SwiftUI previews.
struct MockAnnouncementProvider: AnnouncementProviding {
let announcements: [Announcement]
var shouldFail: Bool = false
func fetchAnnouncements() async throws -> [Announcement] {
if shouldFail {
throw AnnouncementError.fetchFailed
}
return announcements
}
}
// MARK: - Errors
enum AnnouncementError: Error, LocalizedError {
case fetchFailed
case decodingFailed
var errorDescription: String? {
switch self {
case .fetchFailed:
return "Failed to fetch announcements from server."
case .decodingFailed:
return "Failed to decode announcement data."
}
}
}AnnouncementBannerView.swift
import SwiftUI
/// A style-aware banner view for displaying announcements.
///
/// Renders with appropriate colors and icon based on the announcement style:
/// - Info: blue with info.circle icon
/// - Warning: orange with exclamationmark.triangle icon
/// - Success: green with checkmark.circle icon
/// - Promotion: purple with star.fill icon
///
/// Usage:
/// ```swift
/// AnnouncementBannerView(
/// announcement: announcement,
/// onAction: { handleAction($0) },
/// onDismiss: { manager.dismiss(announcement) }
/// )
/// ```
struct AnnouncementBannerView: View {
let announcement: Announcement
let onAction: (Announcement.Action) -> Void
let onDismiss: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Style icon
Image(systemName: iconName)
.font(.title3)
.foregroundStyle(styleColor)
.frame(width: 24, height: 24)
// Content
VStack(alignment: .leading, spacing: 4) {
Text(announcement.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(announcement.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
// Action button (if not dismiss-only)
if case .dismiss = announcement.action {
// No action button for dismiss-only
} else {
Button {
onAction(announcement.action)
} label: {
Text(actionButtonTitle)
.font(.caption.weight(.medium))
}
.buttonStyle(.bordered)
.tint(styleColor)
.controlSize(.small)
.padding(.top, 4)
}
}
Spacer(minLength: 0)
// Dismiss button
if announcement.isDismissible {
Button {
onDismiss()
} label: {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Dismiss announcement")
}
}
.padding(16)
.background(bannerBackground)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
.padding(.horizontal, 16)
}
// MARK: - Style Properties
private var iconName: String {
switch announcement.style {
case .info: return "info.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .success: return "checkmark.circle.fill"
case .promotion: return "star.fill"
}
}
private var styleColor: Color {
switch announcement.style {
case .info: return .blue
case .warning: return .orange
case .success: return .green
case .promotion: return .purple
}
}
private var actionButtonTitle: String {
switch announcement.action {
case .deepLink: return "View"
case .url: return "Learn More"
case .dismiss: return ""
}
}
private var bannerBackground: some ShapeStyle {
#if canImport(UIKit)
return Color(uiColor: .secondarySystemBackground)
#elseif canImport(AppKit)
return Color(nsColor: .controlBackgroundColor)
#endif
}
}
// MARK: - Previews
#Preview("Info Banner") {
AnnouncementBannerView(
announcement: Announcement(
id: "preview-info",
title: "App Update Available",
message: "Version 2.5 includes performance improvements and bug fixes.",
style: .info,
action: .url(URL(string: "https://example.com")!),
priority: 5
),
onAction: { _ in },
onDismiss: { }
)
.padding()
}
#Preview("Warning Banner") {
AnnouncementBannerView(
announcement: Announcement(
id: "preview-warning",
title: "Scheduled Maintenance",
message: "Service will be unavailable Saturday 2-4 AM EST.",
style: .warning,
action: .dismiss,
priority: 10,
isDismissible: false
),
onAction: { _ in },
onDismiss: { }
)
.padding()
}
#Preview("Promotion Banner") {
AnnouncementBannerView(
announcement: Announcement(
id: "preview-promo",
title: "Summer Sale - 40% Off!",
message: "Upgrade to Pro at our lowest price ever. Limited time offer.",
style: .promotion,
action: .deepLink("app://subscription/upgrade"),
priority: 8
),
onAction: { _ in },
onDismiss: { }
)
.padding()
}AnnouncementBannerModifier.swift
import SwiftUI
/// Banner position relative to the screen.
enum AnnouncementBannerPosition {
case top
case bottom
case floating
}
/// ViewModifier that overlays an announcement banner on the view.
///
/// Automatically loads announcements on appear, handles animations,
/// and routes actions through the provided handler.
///
/// Usage:
/// ```swift
/// ContentView()
/// .announcementBanner(position: .top) { action in
/// switch action {
/// case .deepLink(let path): router.navigate(to: path)
/// case .url(let url): openURL(url)
/// case .dismiss: break
/// }
/// }
/// ```
struct AnnouncementBannerModifier: ViewModifier {
let position: AnnouncementBannerPosition
let actionHandler: ((Announcement.Action) -> Void)?
@Environment(\.announcementManager) private var manager
@Environment(\.openURL) private var openURL
@State private var isVisible = false
func body(content: Content) -> some View {
content
.overlay(alignment: overlayAlignment) {
if let announcement = manager.activeAnnouncement, isVisible {
bannerView(for: announcement)
.transition(bannerTransition)
.zIndex(1000)
}
}
.task {
await manager.loadAnnouncements()
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isVisible = manager.activeAnnouncement != nil
}
}
.onChange(of: manager.activeAnnouncement?.id) { _, newValue in
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isVisible = newValue != nil
}
}
}
// MARK: - Banner View
@ViewBuilder
private func bannerView(for announcement: Announcement) -> some View {
AnnouncementBannerView(
announcement: announcement,
onAction: { action in
handleAction(action)
if announcement.isDismissible {
dismissWithAnimation(announcement)
}
},
onDismiss: {
dismissWithAnimation(announcement)
}
)
.padding(.top, position == .top ? 8 : 0)
.padding(.bottom, position == .bottom ? 8 : 0)
.accessibilityAddTraits(.isStaticText)
.onAppear {
#if canImport(UIKit)
UIAccessibility.post(
notification: .announcement,
argument: "\(announcement.title). \(announcement.message)"
)
#elseif canImport(AppKit)
NSAccessibility.post(
element: NSApp as Any,
notification: .announcementRequested
)
#endif
}
}
// MARK: - Action Handling
private func handleAction(_ action: Announcement.Action) {
if let handler = actionHandler {
handler(action)
return
}
// Default action handling
switch action {
case .deepLink(let destination):
if let url = URL(string: destination) {
openURL(url)
}
case .url(let url):
openURL(url)
case .dismiss:
break
}
}
// MARK: - Animation Helpers
private func dismissWithAnimation(_ announcement: Announcement) {
withAnimation(.spring(duration: 0.3, bounce: 0.1)) {
isVisible = false
}
// Delay the actual dismissal to allow animation to complete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
manager.dismiss(announcement)
}
}
private var overlayAlignment: Alignment {
switch position {
case .top: return .top
case .bottom: return .bottom
case .floating: return .center
}
}
private var bannerTransition: AnyTransition {
switch position {
case .top:
return .asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
)
case .bottom:
return .asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
)
case .floating:
return .asymmetric(
insertion: .scale(scale: 0.9).combined(with: .opacity),
removal: .scale(scale: 0.9).combined(with: .opacity)
)
}
}
}
// MARK: - View Extension
extension View {
/// Adds an announcement banner overlay to the view.
///
/// ```swift
/// NavigationStack { ... }
/// .announcementBanner()
/// ```
func announcementBanner(
position: AnnouncementBannerPosition = .top,
actionHandler: ((Announcement.Action) -> Void)? = nil
) -> some View {
modifier(AnnouncementBannerModifier(
position: position,
actionHandler: actionHandler
))
}
}AnnouncementScheduler.swift
import Foundation
/// Filters announcements based on date ranges and timezone-aware scheduling.
///
/// Handles start/end date filtering with proper UTC handling.
/// Announcements without dates are considered always active.
///
/// Usage:
/// ```swift
/// let scheduler = AnnouncementScheduler()
/// let isActive = scheduler.isActive(announcement, at: Date())
/// ```
struct AnnouncementScheduler: Sendable {
/// Check if an announcement is currently active based on its date range.
///
/// - Parameters:
/// - announcement: The announcement to check.
/// - date: The reference date (defaults to now).
/// - Returns: `true` if the announcement is within its active window.
func isActive(_ announcement: Announcement, at date: Date = Date()) -> Bool {
// If no dates set, always active
if announcement.startDate == nil && announcement.endDate == nil {
return true
}
// Check start date
if let startDate = announcement.startDate, date < startDate {
return false
}
// Check end date
if let endDate = announcement.endDate, date > endDate {
return false
}
return true
}
/// Filter a list of announcements to only those currently active.
func activeAnnouncements(
from announcements: [Announcement],
at date: Date = Date()
) -> [Announcement] {
announcements.filter { isActive($0, at: date) }
}
/// Get the next announcement that will become active.
///
/// Useful for scheduling a refresh when the next announcement starts.
func nextActivation(
from announcements: [Announcement],
after date: Date = Date()
) -> (announcement: Announcement, activationDate: Date)? {
announcements
.filter { announcement in
guard let startDate = announcement.startDate else { return false }
return startDate > date
}
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
.first
.flatMap { announcement in
guard let startDate = announcement.startDate else { return nil }
return (announcement, startDate)
}
}
/// Get the next expiration date among active announcements.
///
/// Useful for scheduling a refresh when an announcement expires.
func nextExpiration(
from announcements: [Announcement],
after date: Date = Date()
) -> Date? {
announcements
.filter { isActive($0, at: date) }
.compactMap { $0.endDate }
.filter { $0 > date }
.sorted()
.first
}
}App Clip Patterns & Constraints
App Clip Size Budget
The 10 MB Limit
App Clips must be under 10 MB after App Thinning. This is a hard limit enforced by the system — if exceeded, the App Clip will not launch.
Strategies to Stay Under 10 MB
| Strategy | Savings | How |
|---|---|---|
| Use SF Symbols | ~2-5 MB | Replace custom icons with system symbols |
| Lazy-load images | ~3-10 MB | Download images from network instead of bundling |
| Minimize dependencies | ~1-5 MB | No SPM packages if possible; inline small utilities |
| Asset catalog optimization | ~1-3 MB | Use vector PDFs, remove unused assets |
| Share code via framework | Varies | Reference shared framework instead of duplicating |
| Remove unused localizations | ~0.5-2 MB | Include only essential languages |
Checking App Clip Size
# Build and export archive to check thinned size
xcodebuild archive \
-scheme "MyAppClip" \
-archivePath ./build/MyAppClip.xcarchive
xcodebuild -exportArchive \
-archivePath ./build/MyAppClip.xcarchive \
-exportPath ./build/export \
-exportOptionsPlist ExportOptions.plist
# Check the App Thinning Size Report
cat ./build/export/App\ Thinning\ Size\ Report.txtXcode Size Monitoring
Add a Run Script build phase to warn when approaching the limit:
# Warn if App Clip exceeds 8 MB (leaving 2 MB buffer)
APP_CLIP_SIZE=$(stat -f%z "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}")
LIMIT=$((8 * 1024 * 1024))
if [ "$APP_CLIP_SIZE" -gt "$LIMIT" ]; then
echo "warning: App Clip binary is $(($APP_CLIP_SIZE / 1024 / 1024)) MB — approaching 10 MB limit"
fiAvailable vs Unavailable Frameworks
Available in App Clips
| Framework | Notes |
|---|---|
| SwiftUI | Full support |
| UIKit | Full support |
| CoreLocation | Location confirmation only (no continuous tracking) |
| MapKit | Display maps |
| StoreKit | SKOverlay for full app promotion |
| WebKit | Limited web views |
| AVFoundation | Media playback |
| CoreImage | Image processing |
| CoreML | On-device ML (watch binary size) |
| AuthenticationServices | Sign in with Apple |
| PassKit | Apple Pay |
NOT Available in App Clips
| Framework | Alternative |
|---|---|
| CallKit | Not available — prompt full app download |
| HealthKit | Not available — prompt full app download |
| CareKit | Not available — prompt full app download |
| HomeKit | Not available — prompt full app download |
| ResearchKit | Not available — prompt full app download |
| SensorKit | Not available — prompt full app download |
Limited in App Clips
| Capability | Limitation |
|---|---|
| Background modes | No background fetch, no silent push |
| Push notifications | Ephemeral notifications only (8-hour window) |
| Keychain | Data cleared when App Clip data is deleted |
| File system | Sandbox is temporary — data deleted after inactivity |
Data Lifecycle
The 8-Hour Rule
User invokes App Clip
↓
App Clip launches, creates data
↓
User uses App Clip, then leaves
↓
8 hours of inactivity
↓
System deletes ALL App Clip data:
- UserDefaults
- Documents directory
- Caches directory
- Keychain items
↓
Only App Group data persists (for full app migration)Data Persistence Strategy
// ❌ Wrong — data will be lost after 8 hours
UserDefaults.standard.set(orderID, forKey: "lastOrder")
// ✅ Right — persist in App Group for full app to access
let shared = UserDefaults(suiteName: "group.com.yourapp")
shared?.set(orderID, forKey: "lastOrder")Ephemeral-to-Persistent Migration
When the user installs the full app, migrate data from the App Group:
// In full app's AppDelegate or root view
func migrateAppClipData() {
let shared = UserDefaults(suiteName: "group.com.yourapp")
if let pendingOrderData = shared?.data(forKey: "pendingOrder") {
let order = try? JSONDecoder().decode(Order.self, from: pendingOrderData)
// Import into full app's persistent store (Core Data, SwiftData, etc.)
if let order {
persistentStore.save(order)
}
// Clean up shared data
shared?.removeObject(forKey: "pendingOrder")
}
}Invocation URL Configuration
App Store Connect Setup
1. Navigate to your app in App Store Connect 2. Go to App Clip section 3. Add App Clip Experiences:
- URL: The invocation URL prefix (e.g.,
https://example.com/clip/) - Card Image: 3000 x 2000 px (1.5:1 ratio)
- Title: Up to 30 characters
- Subtitle: Brief description of the experience
- Call-to-Action: Button text (Open, View, Play, etc.)
Associated Domains Entitlement
Both the main app and the App Clip must include:
<!-- Main App and App Clip .entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>appclips:example.com</string>
</array>Apple-App-Site-Association (AASA) File
Host at https://example.com/.well-known/apple-app-site-association:
{
"appclips": {
"apps": [
"TEAM_ID.com.yourapp.Clip"
]
},
"applinks": {
"apps": [],
"details": [
{
"appIDs": [
"TEAM_ID.com.yourapp",
"TEAM_ID.com.yourapp.Clip"
],
"components": [
{
"/": "/clip/*",
"comment": "App Clip invocation URLs"
}
]
}
]
}
}URL Pattern Registration
Register specific URL patterns for different experiences:
https://example.com/clip/order?location=* → Order experience
https://example.com/clip/reserve?venue=* → Reserve experience
https://example.com/clip/checkin?event=* → Check-in experience
https://example.com/clip/product/* → Product previewPhysical Invocation
NFC Tag Programming
Program NFC tags with your App Clip URL:
import CoreNFC
func writeAppClipURL(to tag: NFCNDEFTag, locationID: String) async throws {
let urlString = "https://example.com/clip/order?location=\(locationID)"
guard let url = URL(string: urlString) else { return }
let payload = NFCNDEFPayload.wellKnownTypeURIPayload(url: url)!
let message = NFCNDEFMessage(records: [payload])
try await tag.writeNDEF(message)
}QR Code Generation
Generate QR codes that invoke the App Clip:
import CoreImage
func generateAppClipQRCode(for locationID: String, size: CGFloat = 200) -> UIImage? {
let urlString = "https://example.com/clip/order?location=\(locationID)"
guard let data = urlString.data(using: .utf8),
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("H", forKey: "inputCorrectionLevel") // High error correction
guard let ciImage = filter.outputImage else { return nil }
let scale = size / ciImage.extent.size.width
let scaledImage = ciImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
return UIImage(ciImage: scaledImage)
}App Clip Code Design
App Clip Codes are Apple-designed visual codes (similar to QR codes but with the App Clip logo). Generate them in App Store Connect:
1. Go to your App Clip experience in App Store Connect 2. Select Create App Clip Code 3. Choose style: NFC-integrated (NFC + visual) or Scan-only (visual only) 4. Download SVG for print
Testing
Local Testing with Xcode
Set the _XCAppClipURL environment variable in the App Clip scheme:
1. Edit Scheme > Run > Arguments > Environment Variables 2. Add: _XCAppClipURL = https://example.com/clip/order?location=store-42 3. Run the App Clip target — it will receive the URL on launch
TestFlight Testing
1. Archive and upload the main app (which includes the App Clip) 2. In TestFlight, add testers 3. Provide test invocation URLs to testers 4. Testers can invoke the App Clip from Safari, QR code, or NFC
Unit Testing the Invocation Handler
import Testing
@Suite("InvocationHandler")
struct InvocationHandlerTests {
@Test("Parses order URL with location parameter")
func parseOrderURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/order?location=store-42")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .orderFood)
#expect(experience?.parameters["locationID"] == "store-42")
}
@Test("Parses reservation URL with venue parameter")
func parseReserveURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/reserve?venue=restaurant-7")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .reserve)
#expect(experience?.parameters["venueID"] == "restaurant-7")
}
@Test("Parses check-in URL with event parameter")
func parseCheckInURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/checkin?event=concert-123")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .checkIn)
#expect(experience?.parameters["eventID"] == "concert-123")
}
@Test("Parses product URL with path parameter")
func parseProductURL() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/product/abc123")!
let experience = handler.parseURL(url)
#expect(experience != nil)
#expect(experience?.experienceType == .previewContent)
#expect(experience?.parameters["productID"] == "abc123")
}
@Test("Rejects URL from unregistered domain")
func rejectUnregisteredDomain() {
let handler = InvocationHandler()
let url = URL(string: "https://other-domain.com/clip/order?location=store-1")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects URL without clip path prefix")
func rejectMissingClipPrefix() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/order?location=store-1")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects URL with unknown action")
func rejectUnknownAction() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/unknown?param=value")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
@Test("Rejects order URL missing required location parameter")
func rejectMissingLocationParam() {
let handler = InvocationHandler()
let url = URL(string: "https://example.com/clip/order")!
let experience = handler.parseURL(url)
#expect(experience == nil)
}
}Testing Shared Data Manager
@Suite("SharedDataManager")
struct SharedDataManagerTests {
struct TestOrder: Codable, Equatable {
let id: String
let items: [String]
let total: Double
}
@Test("Round-trips Codable data")
func roundTrip() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-1", items: ["Latte", "Muffin"], total: 9.48)
manager.save(order, forKey: "testOrder")
let loaded: TestOrder? = manager.load(forKey: "testOrder")
#expect(loaded == order)
// Cleanup
manager.remove(forKey: "testOrder")
}
@Test("Returns nil for missing key")
func missingKey() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let result: TestOrder? = manager.load(forKey: "nonexistent")
#expect(result == nil)
}
@Test("Stores timestamp alongside data")
func timestampStorage() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-2", items: ["Espresso"], total: 3.50)
let before = Date()
manager.save(order, forKey: "timestampTest")
let after = Date()
let timestamp = manager.timestamp(forKey: "timestampTest")
#expect(timestamp != nil)
#expect(timestamp! >= before)
#expect(timestamp! <= after)
// Cleanup
manager.remove(forKey: "timestampTest")
}
@Test("Detects pending migration keys")
func pendingMigration() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-3", items: ["Tea"], total: 2.50)
manager.save(order, forKey: "pendingOrder")
let pending = manager.pendingMigrationKeys(from: ["pendingOrder", "otherKey"])
#expect(pending == ["pendingOrder"])
// Cleanup
manager.remove(forKey: "pendingOrder")
}
@Test("Clears migrated data")
func clearMigrated() {
let manager = SharedDataManager(suiteName: "group.test.appclip")
let order = TestOrder(id: "order-4", items: ["Cookie"], total: 1.99)
manager.save(order, forKey: "migrateTest")
manager.clearMigratedData(keys: ["migrateTest"])
let loaded: TestOrder? = manager.load(forKey: "migrateTest")
#expect(loaded == nil)
#expect(manager.timestamp(forKey: "migrateTest") == nil)
}
}Best Practices
Instant Value
The user tapped an NFC tag or scanned a QR code — they expect immediate results. Every second of delay increases abandonment.
// ✅ Show UI immediately, load data in background
struct OrderExperienceView: View {
@State private var isLoading = true
var body: some View {
// Skeleton UI appears instantly
if isLoading {
OrderSkeletonView()
} else {
OrderContentView()
}
}
}
// ❌ Blank screen while loading
struct OrderExperienceView: View {
var body: some View {
ProgressView() // User sees spinner, no context
}
}No Sign-In Required
App Clips must provide value without authentication. Defer sign-in to the full app.
// ✅ Allow anonymous ordering, collect identity later
struct OrderFlow {
func placeOrder(items: [MenuItem]) async {
// Create order without requiring account
let order = Order(items: items, guestID: UUID().uuidString)
await submitOrder(order)
}
}
// ❌ Block the experience with a login screen
struct OrderFlow {
func placeOrder() {
showLoginSheet() // User leaves immediately
}
}Clear Upgrade Path
After the user completes their task, show the value of the full app:
// ✅ Show upgrade after task completion
struct OrderConfirmationView: View {
var body: some View {
VStack {
// Order confirmation content
OrderReceiptView(order: order)
Spacer()
// Contextual upgrade prompt
UpgradeBanner(appStoreID: "123456789")
}
}
}Minimal Permissions
Request only what is absolutely necessary. Every permission prompt is friction.
// ✅ Only request location for physical-location experiences
// ✅ Use Sign in with Apple (minimal friction) if auth is needed
// ✅ Use Apple Pay (no form filling)
// ❌ Don't request notification permission in App Clip
// ❌ Don't request camera unless core to the experience
// ❌ Don't request contacts, calendar, etc.Anti-Patterns to Avoid
Don't Bundle Large Assets
// ❌ 5 MB image bundled in asset catalog
Image("hero-background") // Eats half the size budget
// ✅ Use SF Symbols or load from network
Image(systemName: "fork.knife.circle.fill")
.font(.system(size: 60))Don't Use Heavy Dependencies
// ❌ Adding Alamofire, SDWebImage, etc. bloats the binary
// Each SPM dependency can add 0.5-2 MB
// ✅ Use URLSession directly — it's already available
let (data, response) = try await URLSession.shared.data(from: url)Don't Persist Sensitive Data in App Clip Sandbox
// ❌ Keychain data is deleted with App Clip data
try keychain.set(token, forKey: "authToken")
// ✅ Store in App Group if full app needs it
SharedDataManager.shared.save(token, forKey: "authToken")Don't Ignore the Size Budget During Development
// ❌ "We'll optimize later" — then you're 15 MB at submission
// ✅ Check size on every PR
// Add a CI step that builds the App Clip and checks the thinned sizeApple Human Interface Guidelines — App Icons
Reference for generating HIG-compliant app icons.
Universal Principles
Simplicity
- Embrace simplicity. Find a single element that captures the essence of your app.
- Express that element in a unique shape or combination of shapes.
- Add detail carefully — too much makes the icon muddy at small sizes.
Recognizability
- People should be able to identify your icon at a glance.
- Avoid replicas of Apple hardware or system icons.
- Use a unique silhouette — if you squint, can you tell it apart from other icons?
Consistency
- Use a single, centered, front-facing perspective.
- No dramatic tilts, 3D rotations, or extreme angles.
- The icon should feel like it belongs on the platform.
No Text
- Don't include words in your app icon.
- Text is unreadable at small sizes and doesn't localize.
- Exception: Single letters can work if they ARE the brand (like "A" for App Store).
Platform-Specific Guidelines
macOS Icons
- Shape: Square canvas, system applies rounded rect mask with ~18.75% corner radius
- Size range: 16x16 to 512x512@2x (1024x1024)
- Detail level: Can be more detailed than iOS — icons display larger in Finder, Dock
- Visual weight: Centered in canvas, fill ~80% of the space
- Background: Must fill the entire canvas (no transparency — the system mask handles the shape)
Required sizes:
| Size | Scale | Pixels | Context |
|---|---|---|---|
| 16x16 | 1x | 16 | Finder sidebar, Spotlight |
| 16x16 | 2x | 32 | Retina Finder sidebar |
| 32x32 | 1x | 32 | Finder list view |
| 32x32 | 2x | 64 | Retina Finder list view |
| 128x128 | 1x | 128 | Finder icon view |
| 128x128 | 2x | 256 | Retina Finder icon view |
| 256x256 | 1x | 256 | Finder preview |
| 256x256 | 2x | 512 | Retina Finder preview |
| 512x512 | 1x | 512 | App Store (legacy) |
| 512x512 | 2x | 1024 | App Store, Marketing |
iOS Icons
- Shape: Square canvas, system applies continuous rounded rect (squircle) mask
- Size: Single 1024x1024 PNG, system auto-generates all sizes
- Detail level: Keep simpler than macOS — icons are smaller on screen
- Background: Must fill canvas, no transparency, no rounded corners (system does this)
- No alpha channel: iOS icons must be opaque
watchOS Icons
- Shape: Circular mask applied by system
- Size: 1024x1024 master, system generates circular variants
- Detail level: Very simple — icons are tiny on Watch
Design Techniques
Backgrounds
Linear Gradient (most common):
- Top-to-bottom or diagonal
- Use 2 colors maximum
- Darker at bottom, lighter at top (natural lighting)
- Don't use pure black (#000000) — use very dark colors instead
Radial Gradient (for depth):
- Center glow behind the primary element
- Very subtle (10-20% opacity)
- Same hue family as the primary accent
Solid Color:
- Works for bold, simple icons
- Use a slightly lighter center for subtle depth
Primary Elements
Best practices for the focal shape:
- Occupy 50-70% of the canvas
- Centered or very slightly above center
- Use filled shapes, not outlines (outlines disappear at small sizes)
- Consistent stroke weights if using lines (minimum 2% of canvas = ~20px at 1024)
Shape rendering at small sizes:
- Test at 16x16 and 32x32 — if the shape is unrecognizable, simplify
- Thick strokes (3%+ of canvas) survive downscaling
- Thin details (<1% of canvas) vanish at small sizes
- Round shapes scale better than sharp corners
Color
Color count: 2-4 colors maximum (background gradient + 1-2 accent colors)
Contrast ratio: Focal element should have at least 3:1 contrast against background
System colors (Apple's palette, good defaults):
| Name | Hex | Use |
|---|---|---|
| System Red | #FF3B30 | Alerts, recording, deletion |
| System Orange | #FF9500 | Warnings, energy |
| System Yellow | #FFCC00 | Highlights, favorites |
| System Green | #34C759 | Success, active, health |
| System Blue | #007AFF | Links, primary actions |
| System Indigo | #5856D6 | Premium, creative |
| System Purple | #AF52DE | Creative, unique |
| System Pink | #FF2D55 | Love, social |
| System Teal | #5AC8FA | Information, calm |
Depth and Polish
Specular highlight (shine):
- Subtle elliptical highlight on the top half of the focal element
- White at 15-25% opacity, fading to transparent
- Suggests a physical, tactile surface
Drop shadow:
- Very subtle (5-10% opacity)
- Small offset (1-2% of canvas)
- Only if the element is "floating" above the background
Outer ring/border:
- Thin ring around the primary element (1-2% of canvas width)
- White at 15-25% opacity
- Adds definition and structure
Glow:
- Radial gradient behind the primary element
- Same hue as the element, 10-20% opacity
- Radius ~2x the element's radius
- Creates a "lit from within" effect
Anti-Patterns (What NOT to Do)
1. No photos or screenshots — they become unrecognizable blobs at small sizes 2. No text or words — unreadable at 16x16 3. No Apple hardware — violates guidelines and trademark 4. No transparency (iOS) — must be fully opaque 5. No manual rounded corners — the system applies the mask 6. No borders that follow the icon shape — the system mask clips them unevenly 7. No overly detailed illustrations — test at 32x32, if it's muddy, simplify 8. No pure white backgrounds — blends with light mode UI, use off-white or a color 9. No pure black backgrounds — blends with dark mode UI, use very dark color (e.g., #0f0c29) 10. No busy patterns or textures — compete with the focal element
App Store Asset Specifications Reference
Complete specifications for all App Store Connect assets, updated for 2025.
App Icon
| Attribute | Requirement |
|---|---|
| Size | 1024 x 1024 px |
| Format | PNG |
| Color Space | sRGB or Display P3 |
| Transparency | Not allowed |
| Rounded Corners | Do not round — system applies mask |
| Interlaced | Not allowed |
| Layers | Single layer, flat |
Icon Design Guidelines
- Simple, recognizable at small sizes (29x29 px on Watch)
- Single focal point — avoid clutter
- Use brand colors consistently
- No photos — use graphic design or illustration
- No text (except single letters/numbers for brand identity)
- Test at all sizes: 1024, 180, 120, 87, 80, 60, 58, 40, 29, 20
---
Screenshots
iPhone Screenshots
| Device | Display Size | Screenshot Size (Portrait) | Screenshot Size (Landscape) |
|---|---|---|---|
| iPhone 16 Pro Max | 6.9" | 1320 x 2868 px | 2868 x 1320 px |
| iPhone 16 Pro | 6.3" | 1206 x 2622 px | 2622 x 1206 px |
| iPhone 16 Plus | 6.7" | 1290 x 2796 px | 2796 x 1290 px |
| iPhone 16 | 6.1" | 1179 x 2556 px | 2556 x 1179 px |
| iPhone SE | 4.7" | 750 x 1334 px | 1334 x 750 px |
| iPhone 8 Plus | 5.5" | 1242 x 2208 px | 2208 x 1242 px |
Required: 6.9" and 6.7" sizes at minimum. Others are optional but recommended. Count: Minimum 1, maximum 10 per localization.
iPad Screenshots
| Device | Display Size | Screenshot Size (Portrait) | Screenshot Size (Landscape) |
|---|---|---|---|
| iPad Pro 13" (M4) | 13" | 2064 x 2752 px | 2752 x 2064 px |
| iPad Pro 11" (M4) | 11" | 1668 x 2388 px | 2388 x 1668 px |
| iPad 10th gen | 10.9" | 1640 x 2360 px | 2360 x 1640 px |
| iPad mini | 8.3" | 1488 x 2266 px | 2266 x 1488 px |
Required: 13" size at minimum for iPad apps.
Mac Screenshots
| Display | Screenshot Size |
|---|---|
| Mac (Retina) | 2880 x 1800 px (or 1440 x 900 minimum) |
| Mac (standard) | 1280 x 800 px minimum |
Maximum: 2560 x 1600 px or 1600 x 2560 px.
Apple Watch Screenshots
| Device | Screenshot Size |
|---|---|
| Apple Watch Ultra 2 | 502 x 610 px |
| Apple Watch Series 10 (46mm) | 416 x 496 px |
| Apple Watch SE (44mm) | 368 x 448 px |
Apple TV Screenshots
| Size |
|---|
| 3840 x 2160 px or 1920 x 1080 px |
Apple Vision Pro Screenshots
| Size |
|---|
| 3840 x 2160 px |
---
Screenshot Content Guidelines
DO:
- Show actual app UI (can be enhanced/annotated)
- Include captions that explain benefits
- Start with the most compelling screen
- Use device frames for context (optional)
- Show real content (not placeholder Lorem Ipsum)
DON'T:
- Include status bar time/battery unless necessary
- Show competitor apps or references
- Include pricing in screenshots (changes by region)
- Use "iPhone" or "iPad" device images (trademark)
- Include misleading content not in the actual app
Caption Best Practices
- 3-7 words per caption
- Benefit-focused, not feature-focused
- ✅ "Track your progress effortlessly"
- ❌ "Dashboard with charts and statistics"
---
App Preview Video
| Attribute | Requirement |
|---|---|
| Duration | 15-30 seconds |
| Format | H.264, M4V, MP4, or MOV |
| Frame Rate | 30 fps |
| Audio | Optional (256 kbps AAC) |
| Maximum File Size | 500 MB |
Resolution by Device
| Device | Resolution |
|---|---|
| iPhone 16 Pro Max | 1320 x 2868 (portrait) or 2868 x 1320 (landscape) |
| iPhone 16 Plus | 1290 x 2796 or 2796 x 1290 |
| iPad Pro 13" | 2064 x 2752 or 2752 x 2064 |
| Mac | Up to 1920 x 1080 |
Preview Best Practices
- First 3 seconds: Show the core value (most users don't watch all 30s)
- No live-action footage: Must be app screen recording (can overlay text/graphics)
- Poster frame: Choose the most compelling frame as the preview thumbnail
- Audio: If included, use music or narration that adds value
- CTA: End with a clear call to action or key benefit
---
In-App Event Card Images
| Attribute | Requirement |
|---|---|
| Size | 1080 x 1920 px (portrait) |
| Alternate | 1920 x 1080 px (landscape) |
| Format | PNG or JPEG |
| Transparency | Not allowed |
| Safe Zone | Keep critical content in center 80% |
Event Card Video (Optional)
| Attribute | Requirement |
|---|---|
| Duration | Up to 30 seconds |
| Format | H.264, M4V, MP4, or MOV |
| Frame Rate | 30 fps |
| Audio | Optional |
---
Subscription Promotional Image
| Attribute | Requirement |
|---|---|
| Size | 1024 x 1024 px |
| Format | PNG or JPEG |
| Transparency | Not allowed |
| Purpose | Shown on product page for promoted IAPs |
Design Tips
- Feature the value of the subscription visually
- Don't just repeat the app icon
- Show premium features or content
- Keep text minimal — product name is shown separately
- Test at thumbnail size (appears small in search results)
---
Featuring Artwork (If Featured by Apple)
Apple may request additional artwork if your app is selected for featuring.
| Placement | Size | Notes |
|---|---|---|
| Today tab background | 2560 x 1440 px | Full-bleed, atmospheric |
| App of the Day card | 2400 x 1200 px | Feature hero image |
| Collection banner | 2560 x 686 px | Horizontal banner |
These are requested only after Apple confirms featuring — don't pre-create.
---
Asset Production Checklist
Minimum Viable Assets
- [ ] App icon (1024 x 1024)
- [ ] iPhone screenshots (6.9" size) — minimum 3, recommended 5-10
- [ ] iPad screenshots (13" size) — if iPad app
- [ ] App Store description and promotional text
Recommended Additional Assets
- [ ] iPhone screenshots (6.7" size)
- [ ] App preview video (15-30 seconds)
- [ ] Mac screenshots — if Mac app
- [ ] Watch screenshots — if Watch app
- [ ] Localized screenshots for top markets
Promotional Assets (When Needed)
- [ ] In-App Event card image (1080 x 1920)
- [ ] Subscription promotional image (1024 x 1024)
- [ ] Custom Product Page screenshots (per page)
Quality Checks
- [ ] All text is readable at actual display size
- [ ] Screenshots accurately represent current app version
- [ ] App preview shows actual app footage (not mockups)
- [ ] Colors are consistent with brand identity
- [ ] No competitor references or trademark violations
- [ ] Tested in both light and dark mode product page display
import Foundation
import AuthenticationServices
/// Central authentication manager.
///
/// Coordinates Sign in with Apple, biometrics, and session management.
///
/// Usage:
/// ```swift
/// @main
/// struct MyApp: App {
/// @State private var authManager = AuthenticationManager()
///
/// var body: some Scene {
/// WindowGroup {
/// if authManager.isAuthenticated {
/// ContentView()
/// } else {
/// AuthenticationView()
/// }
/// }
/// .environment(authManager)
/// }
/// }
/// ```
@MainActor
@Observable
final class AuthenticationManager {
// MARK: - Published State
/// Whether user is currently authenticated.
private(set) var isAuthenticated = false
/// Current user info (if authenticated).
private(set) var currentUser: AuthenticatedUser?
/// Any authentication error.
private(set) var error: AuthenticationError?
/// Whether authentication is in progress.
private(set) var isLoading = false
// MARK: - Initialization
init() {
// Check for existing session on init
Task {
await checkExistingSession()
}
}
// MARK: - Sign in with Apple
/// Handle Sign in with Apple result.
func handleSignInWithApple(_ result: Result<ASAuthorization, Error>) {
isLoading = true
defer { isLoading = false }
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
error = .invalidCredential
return
}
// Extract user info
let userID = credential.user
// Name only on first sign-in - save immediately
var name: String?
if let fullName = credential.fullName {
name = PersonNameComponentsFormatter().string(from: fullName)
}
// Email may be real or relay
let email = credential.email
// Save to Keychain
saveCredentials(userID: userID, name: name, email: email)
// Update state
currentUser = AuthenticatedUser(
id: userID,
name: name ?? KeychainManager.shared.get(.userName),
email: email ?? KeychainManager.shared.get(.userEmail)
)
isAuthenticated = true
error = nil
case .failure(let authError):
if let asError = authError as? ASAuthorizationError {
switch asError.code {
case .canceled:
// User cancelled - not an error
break
case .failed:
error = .failed(authError)
case .invalidResponse:
error = .invalidCredential
case .notHandled:
error = .failed(authError)
case .unknown:
error = .unknown
case .notInteractive:
error = .failed(authError)
@unknown default:
error = .unknown
}
} else {
error = .failed(authError)
}
}
}
// MARK: - Biometric Authentication
/// Authenticate using Face ID or Touch ID.
func authenticateWithBiometrics() async -> Bool {
isLoading = true
defer { isLoading = false }
let success = await BiometricAuthManager.shared.authenticate()
if success {
// Load saved user
if let userID = KeychainManager.shared.get(.userID) {
currentUser = AuthenticatedUser(
id: userID,
name: KeychainManager.shared.get(.userName),
email: KeychainManager.shared.get(.userEmail)
)
isAuthenticated = true
}
}
return success
}
// MARK: - Sign Out
/// Sign out and clear all credentials.
func signOut() {
KeychainManager.shared.clearAll()
currentUser = nil
isAuthenticated = false
error = nil
}
// MARK: - Credential State
/// Check if existing credentials are still valid.
func checkCredentialState() async {
guard let userID = KeychainManager.shared.get(.userID) else {
isAuthenticated = false
return
}
let state = await SignInWithAppleManager.shared.checkCredentialState(userID: userID)
switch state {
case .authorized:
// Still valid
currentUser = AuthenticatedUser(
id: userID,
name: KeychainManager.shared.get(.userName),
email: KeychainManager.shared.get(.userEmail)
)
isAuthenticated = true
case .revoked, .notFound:
// User revoked or doesn't exist
signOut()
case .transferred:
// Handle account transfer if needed
signOut()
@unknown default:
break
}
}
// MARK: - Private
private func checkExistingSession() async {
guard KeychainManager.shared.get(.userID) != nil else {
return
}
await checkCredentialState()
}
private func saveCredentials(userID: String, name: String?, email: String?) {
KeychainManager.shared.save(userID, for: .userID)
if let name = name {
KeychainManager.shared.save(name, for: .userName)
}
if let email = email {
KeychainManager.shared.save(email, for: .userEmail)
}
}
}
// MARK: - Models
/// Authenticated user information.
struct AuthenticatedUser: Sendable {
let id: String
let name: String?
let email: String?
}
/// Authentication errors.
enum AuthenticationError: Error, LocalizedError {
case invalidCredential
case failed(Error)
case cancelled
case unknown
var errorDescription: String? {
switch self {
case .invalidCredential:
return "Invalid credentials received"
case .failed(let error):
return error.localizedDescription
case .cancelled:
return "Authentication was cancelled"
case .unknown:
return "An unknown error occurred"
}
}
}
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [
"TEAMID.com.yourcompany.yourapp"
],
"components": [
{
"/": "/items/*",
"comment": "Item detail pages"
},
{
"/": "/users/*",
"comment": "User profile pages"
},
{
"/": "/categories/*",
"comment": "Category pages"
},
{
"/": "/share/*",
"comment": "Share links"
},
{
"/": "/search",
"?": {
"q": "*"
},
"comment": "Search with query"
},
{
"/": "/settings",
"comment": "Settings page"
},
{
"/": "/settings/*",
"comment": "Settings sections"
},
{
"/": "/invite",
"?": {
"code": "*"
},
"comment": "Invite links with referral code"
}
]
}
]
},
"webcredentials": {
"apps": [
"TEAMID.com.yourcompany.yourapp"
]
},
"appclips": {
"apps": [
"TEAMID.com.yourcompany.yourapp.Clip"
]
}
}
Related skills
How it compares
Use generators when you need working Swift modules inserted into an existing Xcode project; use Apple review or HIG advisory skills when you only need compliance feedback without code output.
FAQ
How many generators does the Apple generators skill include?
The generators skill documents 53 code generator modules covering logging, analytics, onboarding, StoreKit paywalls, widgets, localization, CI/CD, accessibility, and more. Each module reads project context before emitting Swift tailored to deployment targets.
Does generators replace print statements with structured logging?
Yes. The logging-setup generator audits existing print() calls, creates AppLogger infrastructure with os.log and Logger, and migrates statements to privacy-aware logging levels appropriate for iOS and macOS targets.