How to Manage State in FluidVoice: A Complete Guide to SwiftUI Architecture

FluidVoice centralizes state management through three SwiftUI-compatible ObservableObject singletons: SettingsStore for persistent preferences, AppServices for lazy-loaded heavy services, and MenuBarManager for transient UI state.

The altic-dev/FluidVoice repository demonstrates a production-ready approach to SwiftUI state management that separates concerns between persistence, services, and UI. By leveraging the singleton pattern combined with ObservableObject, the application maintains reactive updates across the view hierarchy while avoiding initialization crashes and ensuring data survives app restarts. Understanding how to manage state in FluidVoice provides a blueprint for building robust macOS menu bar applications with complex audio and speech recognition requirements.

The Three Core State Components

FluidVoice’s architecture divides responsibility across three distinct state containers, each optimized for specific lifecycle and persistence requirements.

SettingsStore: Persistent User Preferences

SettingsStore handles durable user settings including prompt profiles, hotkeys, and the launch-at-startup flag. Implemented as an @MainActor ObservableObject singleton in Sources/Fluid/Persistence/SettingsStore.swift, it persists data to UserDefaults and the Keychain while exposing changes through objectWillChange.

When any view modifies a published property, the setter automatically synchronizes to UserDefaults and notifies subscribers. For example, launchAtStartupEnabled writes to Keys.launchAtStartupEnabled in UserDefaults before calling objectWillChange.send().

AppServices: Heavy-Weight Runtime Services

AppServices manages expensive resources like the AudioHardwareObserver and ASRService (automatic speech recognition). Defined in Sources/Fluid/Services/AppServices.swift, this singleton defers initialization of heavy objects until the UI signals readiness.

The class stores services as private lazy properties (_audioObserver, _asr) and exposes them through computed properties that check isUIReady. This prevents Swift runtime metadata resolution issues that could crash the app during launch.

MenuBarManager tracks volatile UI state including recording status, overlay modes, and processing indicators. Located in Sources/Fluid/Services/MenuBarManager.swift, this @MainActor ObservableObject coordinates between the menu bar and the floating notch overlay.

Its @Published properties—including isRecording, isProcessingActive, and currentOverlayMode—drive the visual state of NotchOverlayManager, ensuring the UI responds immediately to user actions like hotkey triggers.

Implementing the Singleton Pattern with ObservableObject

Both SettingsStore and AppServices expose shared instances via static let shared, allowing any view or service to access state without dependency injection complexity.

In Sources/Fluid/fluidApp.swift, the application entry point initializes these singletons and injects them into the view hierarchy:

@StateObject private var menuBarManager = MenuBarManager()
@StateObject private var appServices: AppServices
@ObservedObject private var settings = SettingsStore.shared

The root view passes these objects downstream using .environmentObject(...), enabling child views to subscribe to changes through @EnvironmentObject or @ObservedObject property wrappers.

Persisting State with UserDefaults

SettingsStore implements concrete key-based persistence using UserDefaults. The store defines type-safe keys (e.g., Keys.dictationPromptProfiles, Keys.hotKeyBindings) and wraps them in computed properties that handle serialization.

When a view updates a setting, the change propagates through the publisher automatically:

Toggle("Launch at login", isOn: $settings.launchAtStartupEnabled)
    .onChange(of: settings.launchAtStartupEnabled) { newValue in
        // Automatically persisted to UserDefaults via SettingsStore.setter
        print("Launch-at-startup is now \(newValue)")
    }

This pattern ensures that preferences like selected dictation profiles survive app restarts while remaining reactive to SwiftUI binding updates.

Lazy Service Initialization

To prevent launch-time crashes related to audio hardware enumeration, AppServices implements a readiness signaling pattern. The UI calls signalUIReady() after the initial layout completes, triggering service initialization:

.onAppear {
    // Signal that the UI has finished its first layout
    AppServices.shared.signalUIReady()
    // Safely initialize heavy services (audio, ASR)
    AppServices.shared.initializeServicesIfNeeded()
}

The initializeServicesIfNeeded() method checks the isUIReady flag before instantiating AudioHardwareObserver or ASRService, ensuring metadata resolution occurs after the SwiftUI environment is fully established.

Managing Transient UI State

MenuBarManager bridges user actions and visual feedback through its published properties. When a user triggers a recording hotkey, the manager sets isRecording = true, which NotchOverlayManager observes to display the floating notch interface.

Views can modify overlay modes directly through bindings:

Picker("Overlay mode", selection: $menuBar.currentOverlayMode) {
    Text("Dictation").tag(OverlayMode.dictation)
    Text("Command").tag(OverlayMode.command)
}
.pickerStyle(SegmentedPickerStyle())

The @Published property wrapper ensures that any change to currentOverlayMode immediately updates all observing views, including the overlay window and menu bar status item.

Code Examples

Reading and Updating Settings

Access persistent preferences through the shared SettingsStore instance:

import SwiftUI

struct PromptSettingsView: View {
    @ObservedObject private var settings = SettingsStore.shared

    var body: some View {
        Toggle("Launch at login", isOn: $settings.launchAtStartupEnabled)
            .onChange(of: settings.launchAtStartupEnabled) { newValue in
                // Persisted automatically via SettingsStore.setter
                print("Launch-at-startup is now \(newValue)")
            }
    }
}

Observing State Changes in Views

Use @EnvironmentObject to access MenuBarManager from deep within the view hierarchy:

struct OverlayModeSwitcher: View {
    @EnvironmentObject var menuBar: MenuBarManager

    var body: some View {
        Picker("Overlay mode", selection: $menuBar.currentOverlayMode) {
            Text("Dictation").tag(OverlayMode.dictation)
            Text("Command").tag(OverlayMode.command)
        }
        .pickerStyle(SegmentedPickerStyle())
    }
}

Initializing Heavy Services

Trigger service initialization after the root view appears:

// In ContentView.onAppear
.onAppear {
    // Signal that the UI has finished its first layout
    AppServices.shared.signalUIReady()
    // Now start heavy services (audio, ASR) safely
    AppServices.shared.initializeServicesIfNeeded()
}

Accessing Prompt Profiles

Retrieve the active dictation prompt or fall back to defaults:

func currentDictationPrompt() -> String {
    let store = SettingsStore.shared
    let profile = store.selectedDictationPromptProfile
    return profile?.prompt ?? SettingsStore.defaultDictationPromptBodyText()
}

Key Files and Architecture

Understanding these source files clarifies how FluidVoice implements its state management strategy:

Summary

  • FluidVoice uses three specialized singletons (SettingsStore, AppServices, MenuBarManager) to separate persistent data, heavy services, and UI state.
  • SettingsStore persists to UserDefaults while exposing changes via ObservableObject, ensuring preferences survive app restarts.
  • AppServices defers initialization until signalUIReady() is called, preventing launch crashes from premature audio hardware access.
  • MenuBarManager coordinates transient state through @Published properties that drive the overlay and menu bar visual feedback.
  • The singleton pattern simplifies access across the view hierarchy while maintaining SwiftUI reactivity through objectWillChange publishers.

Frequently Asked Questions

How does FluidVoice save user preferences between app launches?

SettingsStore writes to UserDefaults and the Keychain whenever a property changes. The class implements custom setters that call objectWillChange.send() to notify SwiftUI views, then persists the value to UserDefaults using type-safe keys like Keys.launchAtStartupEnabled. This ensures that settings such as prompt profiles and hotkey bindings are available immediately when the app restarts.

Why does FluidVoice use singletons instead of dependency injection?

Singletons provide global access to state without complex injection hierarchies in a menu bar app. While MenuBarManager is instantiated fresh in fluidApp.swift, SettingsStore and AppServices use static let shared to ensure exactly one instance manages persistent data and expensive audio resources. This pattern prevents duplicate service initialization while still allowing SwiftUI views to subscribe to changes through @ObservedObject or @EnvironmentObject.

When should I call signalUIReady() in FluidVoice?

Call signalUIReady() after the root view completes its initial layout, typically in onAppear. In ContentView.swift, this method signals AppServices that it is safe to initialize AudioHardwareObserver and ASRService. Calling it too early can trigger Swift runtime crashes during metadata resolution, while calling it ensures heavy initialization occurs after the UI is responsive.

How do views access the recording state to update the overlay?

Views access recording state through MenuBarManager published properties. The isRecording and isProcessingActive properties are marked with @Published, causing SwiftUI to automatically refresh any view using @EnvironmentObject or @ObservedObject. NotchOverlayManager observes these properties to show or hide the floating notch interface when dictation starts or stops.

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 →