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.
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.
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.
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
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
// 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
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)
}
}