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: Transient UI State
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:
Sources/Fluid/fluidApp.swift: Application entry point that injects state objects into the SwiftUI environment.Sources/Fluid/Persistence/SettingsStore.swift: Centralized, persisted user preferences implementingObservableObject.Sources/Fluid/Services/AppServices.swift: Lazy initialization container for audio and speech recognition services.Sources/Fluid/Services/MenuBarManager.swift: Transient UI state manager for recording flags and overlay modes.Sources/Fluid/ContentView.swift: Root UI that consumes injected objects and coordinates the interface.
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
@Publishedproperties that drive the overlay and menu bar visual feedback. - The singleton pattern simplifies access across the view hierarchy while maintaining SwiftUI reactivity through
objectWillChangepublishers.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →