How to Handle Configuration Changes in FluidVoice: A Complete Guide to SettingsStore

FluidVoice handles configuration changes through a centralized SettingsStore singleton that wraps UserDefaults with type-safe accessors, publishes changes via ObservableObject, and broadcasts updates through NotificationCenter to keep the UI synchronized.

All user-tunable preferences in the altic-dev/FluidVoice repository flow through a single architectural entry point. This design ensures that whether you are migrating legacy settings, updating prompt profiles, or reacting to system-wide changes, the configuration layer remains consistent and reactive across the entire application.

The SettingsStore Architecture

The configuration system is built around SettingsStore, a singleton defined in Sources/Fluid/Persistence/SettingsStore.swift. This class encapsulates UserDefaults.standard (initialized at line 25) and exposes a strongly-typed API that prevents direct dictionary access scattered throughout the codebase.

The architecture separates concerns into four distinct layers:

  • Persistence Layer: Handles raw UserDefaults reads and writes. Complex objects like dictationPromptProfiles and appPromptBindings are stored as JSON-encoded blobs using JSONEncoder and JSONDecoder.
  • Domain Model Layer: Types such as PromptMode, DictationPromptProfile, AppPromptBinding, and DictationPromptSelection enforce correct data shapes. For example, PromptMode normalizes legacy string values ("write", "rewrite") to the canonical .edit case (lines 75-99).
  • Business Logic Layer: Methods like effectivePromptBody(for:), promptResolution(for:), and renderDictationUserMessage interpret raw settings into runtime-ready values. The effectivePromptBody method uses promptResolution to combine hidden base prompts with user overrides (lines 1154-1159).
  • UI Binding Layer: SwiftUI views observe SettingsStore.shared and react to objectWillChange emissions. When users interact with controls, the view calls typed setters such as setDictationPromptSelection(_:for:).

Reading and Writing Basic Configuration

For primitive values, SettingsStore provides direct access to the underlying UserDefaults while maintaining type safety. You should never access UserDefaults directly outside of this class.

Reading a Boolean flag:

let isDebugEnabled = SettingsStore.shared.defaults.bool(forKey: "EnableDebugLogs")

Creating a SwiftUI binding:

Toggle("Enable Debug Logs", isOn: Binding(
    get: { SettingsStore.shared.defaults.bool(forKey: "EnableDebugLogs") },
    set: { SettingsStore.shared.defaults.set($0, forKey: "EnableDebugLogs") }
))

When setting values, the store handles validation automatically. All string setters trim whitespace and convert empty strings to nil, preventing configuration pollution from accidental spaces.

Managing Complex Configuration Objects

FluidVoice stores sophisticated domain objects—such as custom dictation prompts and per-app bindings—as JSON blobs. The SettingsStore handles serialization transparently.

Creating a new prompt profile:

let newProfile = SettingsStore.DictationPromptProfile(
    name: "Creative Writing",
    prompt: SettingsStore.defaultPromptBodyText(for: .dictate) + "\n\nBe more expressive.",
    mode: .dictate,
    includeContext: true
)

var profiles = SettingsStore.shared.dictationPromptProfiles
profiles.append(newProfile)
SettingsStore.shared.dictationPromptProfiles = profiles

Updating per-app bindings:

To associate a specific prompt with a particular application, use the upsertAppPromptBinding(for:appBundleID:appName:promptID:) method (lines 704-740). This normalizes the bundle ID, updates the in-memory array, and persists the entire collection back to UserDefaults:

SettingsStore.shared.upsertAppPromptBinding(
    for: .dictate,
    appBundleID: "com.example.myapp",
    appName: "My App",
    promptID: "custom-prompt-123"
)

Migrating Legacy Configuration Data

The SettingsStore initializer runs several migration helpers to ensure backwards compatibility. When handling configuration changes in older versions of the app, the system automatically converts deprecated keys to their modern equivalents.

Key migration methods include:

  • migrateLegacyDictationAIPreferenceIfNeeded: Converts old boolean flags to new selection enums.
  • normalizePromptSelectionsIfNeeded: Ensures all persisted prompt selections match current valid states.

The store also maintains compatibility aliases for renamed keys. For instance, selectedWritePromptID and defaultWritePromptOverride forward to their canonical counterparts, allowing external code to reference either key without data loss.

Subscribing to Configuration Changes

FluidVoice provides two mechanisms for reacting to configuration changes: SwiftUI’s ObservableObject protocol for view-level updates, and NotificationCenter for broader system reactions.

SwiftUI Observation:

Views automatically refresh when SettingsStore emits objectWillChange. The AISettingsView+AdvancedSettings.swift file demonstrates this pattern—toggling “Rewrite Mode linked to global” writes directly to UserDefaults and triggers immediate UI updates (line 1561).

Global Notification Subscription:

For components that need to respond to changes originating outside the SettingsStore (such as system UserDefaults modifications), subscribe to UserDefaults.didChangeNotification:

// In NotchContentViews.swift (line 81) and BottomOverlayView.swift
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
    viewModel.refreshFromSettings()
}

This dual-channel approach ensures that any change—whether programmatic or user-initiated—propagates instantly to every subscriber.

Handling Edge Cases and Validation

The SettingsStore implements several safeguards to maintain configuration integrity:

  • Whitespace trimming: All text-based setters convert empty or whitespace-only strings to nil.
  • Nil vs. empty distinction: The defaultPromptOverride(for:) getter distinguishes between “not set” and “explicitly cleared,” allowing the UI to display built-in defaults while respecting user intent.
  • Legacy value normalization: The PromptMode enum initializer handles legacy string values, ensuring that older configurations map correctly to current behavior.

Summary

  • SettingsStore in Sources/Fluid/Persistence/SettingsStore.swift serves as the single source of truth for all configuration, wrapping UserDefaults.standard with type-safe accessors.
  • Complex objects are JSON-encoded automatically, while primitives map directly to UserDefaults keys.
  • Migration methods like migrateLegacyDictationAIPreferenceIfNeeded handle version upgrades without data loss.
  • SwiftUI views observe SettingsStore.shared via ObservableObject, while background components listen to UserDefaults.didChangeNotification for changes.
  • Validation occurs at the persistence layer, with automatic string trimming and legacy key aliasing.

Frequently Asked Questions

How does FluidVoice persist complex configuration objects?

FluidVoice stores complex objects like DictationPromptProfile and AppPromptBinding as JSON-encoded strings in UserDefaults. The SettingsStore uses JSONEncoder to serialize these structs before writing them, and JSONDecoder to reconstruct them on access. This approach maintains type safety while leveraging the simple key-value storage of UserDefaults.

What triggers UI updates when settings change in FluidVoice?

UI updates trigger through two mechanisms. First, SettingsStore conforms to ObservableObject and calls objectWillChange.send() after modifying values, causing SwiftUI views to recompute. Second, the system broadcasts UserDefaults.didChangeNotification via NotificationCenter, which views like NotchContentViews.swift and BottomOverlayView.swift subscribe to for instantaneous refresh.

How does FluidVoice handle legacy configuration keys?

The SettingsStore initializer runs migration helpers that detect and convert old keys. For example, the normalizePromptSelectionsIfNeeded method ensures legacy prompt selections align with current enums, while properties like selectedWritePromptID maintain aliases that forward to new canonical keys. This ensures backwards compatibility without requiring manual user intervention.

Can I use SettingsStore outside of SwiftUI views?

Yes. While SwiftUI views commonly reference SettingsStore.shared, any class or function can import the store and call its methods. For non-SwiftUI components, subscribe to NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) to observe changes, or poll the typed getters directly when the app becomes active.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →