FS
ForgeStorage

User Preferences

A complete example of a type-safe user preferences layer for a TaskFlow productivity app, backed by KVStoring with a protocol for testability. Observation is provided via ReactiveKVStore.

Define Storage Keys

Group related keys in an enum namespace with dot-separated prefixes.

TaskFlowPrefsKeys.swift
import ForgeKVStore

enum TaskFlowPrefsKeys {
    static let onboardingCompleted = KVKey<Bool>("taskflow.onboardingCompleted")
    static let displayName = KVKey<String?>("taskflow.displayName")
    static let theme = KVKey<AppTheme>("taskflow.theme")
    static let notificationsEnabled = KVKey<Bool>("taskflow.notificationsEnabled")
    static let fontSize = KVKey<Double>("taskflow.fontSize")
}

enum AppTheme: String, Codable, Sendable, Equatable {
    case light, dark, system
}

TaskFlowPrefs Protocol and Implementation

Define a protocol for your preferences service, then implement it using ReactiveKVStore to get both storage and observation. This gives you type-safe computed properties and full testability.

TaskFlowPrefs.swift
protocol TaskFlowPrefsProtocol: Sendable {
    var onboardingCompleted: Bool { get }
    var displayName: String? { get }
    var theme: AppTheme { get }
    var notificationsEnabled: Bool { get }
    var fontSize: Double { get }

    func completeOnboarding() throws
    func setDisplayName(_ name: String?) throws
    func setTheme(_ theme: AppTheme) throws
    func setNotificationsEnabled(_ enabled: Bool) throws
    func setFontSize(_ size: Double) throws
    func resetAll() throws

    func themeStream() -> AsyncStream<AppTheme?>
    func fontSizeStream() -> AsyncStream<Double?>
}

final class TaskFlowPrefs: TaskFlowPrefsProtocol {
    private let reactive: ReactiveKVStore

    init(store: KVStoring) {
        self.reactive = ReactiveKVStore(store)
    }

    // MARK: - Read

    var onboardingCompleted: Bool {
        reactive.get(TaskFlowPrefsKeys.onboardingCompleted) ?? false
    }

    var displayName: String? {
        reactive.get(TaskFlowPrefsKeys.displayName)
    }

    var theme: AppTheme {
        reactive.get(TaskFlowPrefsKeys.theme) ?? .system
    }

    var notificationsEnabled: Bool {
        reactive.get(TaskFlowPrefsKeys.notificationsEnabled) ?? true
    }

    var fontSize: Double {
        reactive.get(TaskFlowPrefsKeys.fontSize) ?? 16.0
    }

    // MARK: - Write

    func completeOnboarding() throws {
        try reactive.set(TaskFlowPrefsKeys.onboardingCompleted, value: true)
    }

    func setDisplayName(_ name: String?) throws {
        try reactive.set(TaskFlowPrefsKeys.displayName, value: name)
    }

    func setTheme(_ theme: AppTheme) throws {
        try reactive.set(TaskFlowPrefsKeys.theme, value: theme)
    }

    func setNotificationsEnabled(_ enabled: Bool) throws {
        try reactive.set(TaskFlowPrefsKeys.notificationsEnabled, value: enabled)
    }

    func setFontSize(_ size: Double) throws {
        try reactive.set(TaskFlowPrefsKeys.fontSize, value: size)
    }

    // MARK: - Reset

    func resetAll() throws {
        try reactive.removeAll()
    }

    // MARK: - Observation (via ReactiveKVStore)

    func themeStream() -> AsyncStream<AppTheme?> {
        reactive.stream(TaskFlowPrefsKeys.theme)
    }

    func fontSizeStream() -> AsyncStream<Double?> {
        reactive.stream(TaskFlowPrefsKeys.fontSize)
    }
}

SwiftUI ViewModel with Observation

Use ReactiveKVStore's stream() via the TaskFlowPrefs wrapper to react to preference changes in your ViewModel.

SettingsViewModel.swift
import SwiftUI

@Observable
final class SettingsViewModel {
    private let prefs: TaskFlowPrefsProtocol

    var theme: AppTheme
    var fontSize: Double
    var notificationsEnabled: Bool
    var displayName: String

    private var observationTask: Task<Void, Never>?

    init(prefs: TaskFlowPrefsProtocol) {
        self.prefs = prefs

        // Initialize with current values
        self.theme = prefs.theme
        self.fontSize = prefs.fontSize
        self.notificationsEnabled = prefs.notificationsEnabled
        self.displayName = prefs.displayName ?? ""
    }

    func startObserving() {
        observationTask = Task { [weak self] in
            guard let self else { return }

            await withTaskGroup(of: Void.self) { group in
                group.addTask {
                    for await theme in self.prefs.themeStream() {
                        await MainActor.run { self.theme = theme ?? .system }
                    }
                }
                group.addTask {
                    for await size in self.prefs.fontSizeStream() {
                        await MainActor.run { self.fontSize = size ?? 16.0 }
                    }
                }
            }
        }
    }

    func stopObserving() {
        observationTask?.cancel()
        observationTask = nil
    }

    // MARK: - Actions

    func updateTheme(_ theme: AppTheme) {
        try? prefs.setTheme(theme)
    }

    func updateFontSize(_ size: Double) {
        try? prefs.setFontSize(size)
    }

    func updateNotifications(_ enabled: Bool) {
        try? prefs.setNotificationsEnabled(enabled)
        self.notificationsEnabled = enabled
    }

    func updateDisplayName(_ name: String) {
        try? prefs.setDisplayName(name.isEmpty ? nil : name)
        self.displayName = name
    }

    func resetAll() {
        try? prefs.resetAll()
        theme = .system
        fontSize = 16.0
        notificationsEnabled = true
        displayName = ""
    }
}

SwiftUI View

SettingsView.swift
struct SettingsView: View {
    @State private var viewModel: SettingsViewModel

    init(prefs: TaskFlowPrefsProtocol) {
        _viewModel = State(initialValue: SettingsViewModel(prefs: prefs))
    }

    var body: some View {
        Form {
            Section("Appearance") {
                Picker("Theme", selection: Binding(
                    get: { viewModel.theme },
                    set: { viewModel.updateTheme($0) }
                )) {
                    Text("System").tag(AppTheme.system)
                    Text("Light").tag(AppTheme.light)
                    Text("Dark").tag(AppTheme.dark)
                }

                HStack {
                    Text("Font Size")
                    Slider(
                        value: Binding(
                            get: { viewModel.fontSize },
                            set: { viewModel.updateFontSize($0) }
                        ),
                        in: 12...24,
                        step: 1
                    )
                    Text("\(Int(viewModel.fontSize))")
                        .monospacedDigit()
                }
            }

            Section("Notifications") {
                Toggle("Enable Notifications", isOn: Binding(
                    get: { viewModel.notificationsEnabled },
                    set: { viewModel.updateNotifications($0) }
                ))
            }

            Section {
                Button("Reset All Settings", role: .destructive) {
                    viewModel.resetAll()
                }
            }
        }
        .navigationTitle("Settings")
        .onAppear { viewModel.startObserving() }
        .onDisappear { viewModel.stopObserving() }
    }
}

Production Setup

AppSetup.swift
// In your app entry point or DI container
let prefs = TaskFlowPrefs(store: UserDefaultsKVStore())

// For widget/extension sharing
let sharedPrefs = TaskFlowPrefs(
    store: UserDefaultsKVStore(suiteName: "group.com.taskflow.shared")
)

Testing

TaskFlowPrefsTests.swift
import Testing

struct TaskFlowPrefsTests {
    let store = InMemoryKVStore()
    let prefs: TaskFlowPrefs

    init() {
        prefs = TaskFlowPrefs(store: store)
    }

    @Test func defaultValues() {
        #expect(!prefs.onboardingCompleted)
        #expect(prefs.displayName == nil)
        #expect(prefs.theme == .system)
        #expect(prefs.notificationsEnabled)
        #expect(prefs.fontSize == 16.0)
    }

    @Test func setAndReadTheme() throws {
        try prefs.setTheme(.dark)
        #expect(prefs.theme == .dark)
    }

    @Test func resetClearsAll() throws {
        try prefs.completeOnboarding()
        try prefs.setTheme(.dark)
        try prefs.setFontSize(20)
        try prefs.resetAll()

        #expect(!prefs.onboardingCompleted)
        #expect(prefs.theme == .system)
        #expect(prefs.fontSize == 16.0)
    }
}