refactor(observation): migrate SwiftUI state

This commit is contained in:
Peter Steinberger
2025-12-14 05:04:58 +00:00
parent aab5c490dc
commit b48a556de5
43 changed files with 253 additions and 194 deletions

View File

@@ -1,18 +1,18 @@
import ClawdisKit
import Combine
import Foundation
import Network
import Observation
import SwiftUI
@MainActor
final class BridgeConnectionController: ObservableObject {
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryStatusText: String = "Idle"
@Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
@Observable
final class BridgeConnectionController {
private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel?
private var cancellables = Set<AnyCancellable>()
private var didAutoConnect = false
private var seenStableIDs = Set<String>()
@@ -23,20 +23,8 @@ final class BridgeConnectionController: ObservableObject {
self.discovery.setDebugLoggingEnabled(
UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.$bridges
.sink { [weak self] newValue in
guard let self else { return }
self.bridges = newValue
self.updateLastDiscoveredBridge(from: newValue)
self.maybeAutoConnect()
}
.store(in: &self.cancellables)
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
self.discovery.$debugLog
.assign(to: &self.$discoveryDebugLog)
self.updateFromDiscovery()
self.observeDiscovery()
if startDiscovery {
self.discovery.start()
@@ -58,6 +46,29 @@ final class BridgeConnectionController: ObservableObject {
}
}
private func updateFromDiscovery() {
let newBridges = self.discovery.bridges
self.bridges = newBridges
self.discoveryStatusText = self.discovery.statusText
self.discoveryDebugLog = self.discovery.debugLog
self.updateLastDiscoveredBridge(from: newBridges)
self.maybeAutoConnect()
}
private func observeDiscovery() {
withObservationTracking {
_ = self.discovery.bridges
_ = self.discovery.statusText
_ = self.discovery.debugLog
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
self.updateFromDiscovery()
self.observeDiscovery()
}
}
}
private func maybeAutoConnect() {
guard !self.didAutoConnect else { return }
guard let appModel = self.appModel else { return }

View File

@@ -2,7 +2,7 @@ import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(BridgeConnectionController.self) private var bridgeController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {

View File

@@ -1,9 +1,11 @@
import ClawdisKit
import Foundation
import Network
import Observation
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
@Observable
final class BridgeDiscoveryModel {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
@@ -18,9 +20,9 @@ final class BridgeDiscoveryModel: ObservableObject {
var debugID: String
}
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
@Published private(set) var debugLog: [DebugLogEntry] = []
var bridges: [DiscoveredBridge] = []
var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser?
private var debugLoggingEnabled = false

View File

@@ -3,12 +3,12 @@ import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel
@State private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject(
wrappedValue: ClawdisChatViewModel(
self._viewModel = State(
initialValue: ClawdisChatViewModel(
sessionKey: sessionKey,
transport: transport))
}

View File

@@ -2,23 +2,23 @@ import SwiftUI
@main
struct ClawdisApp: App {
@StateObject private var appModel: NodeAppModel
@StateObject private var bridgeController: BridgeConnectionController
@State private var appModel: NodeAppModel
@State private var bridgeController: BridgeConnectionController
@Environment(\.scenePhase) private var scenePhase
init() {
BridgeSettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
_appModel = StateObject(wrappedValue: appModel)
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
_appModel = State(initialValue: appModel)
_bridgeController = State(initialValue: BridgeConnectionController(appModel: appModel))
}
var body: some Scene {
WindowGroup {
RootCanvas()
.environmentObject(self.appModel)
.environmentObject(self.appModel.voiceWake)
.environmentObject(self.bridgeController)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.bridgeController)
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
}

View File

@@ -1,16 +1,18 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
@MainActor
final class NodeAppModel: ObservableObject {
@Published var isBackgrounded: Bool = false
@Observable
final class NodeAppModel {
var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
@Published var bridgeStatusText: String = "Not connected"
@Published var bridgeServerName: String?
@Published var bridgeRemoteAddress: String?
@Published var connectedBridgeID: String?
var bridgeStatusText: String = "Not connected"
var bridgeServerName: String?
var bridgeRemoteAddress: String?
var connectedBridgeID: String?
private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>?

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootCanvas: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct RootTabs: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@State private var voiceWakeToastText: String?

View File

@@ -1,14 +1,16 @@
import ClawdisKit
import Observation
import SwiftUI
import WebKit
@MainActor
final class ScreenController: ObservableObject {
@Observable
final class ScreenController {
let webView: WKWebView
@Published var mode: ClawdisScreenMode = .canvas
@Published var urlString: String = ""
@Published var errorText: String?
var mode: ClawdisScreenMode = .canvas
var urlString: String = ""
var errorText: String?
init() {
let config = WKWebViewConfiguration()

View File

@@ -2,7 +2,7 @@ import ClawdisKit
import SwiftUI
struct ScreenTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ZStack(alignment: .top) {

View File

@@ -3,7 +3,7 @@ import SwiftUI
import WebKit
struct ScreenWebView: UIViewRepresentable {
@ObservedObject var controller: ScreenController
var controller: ScreenController
func makeUIView(context: Context) -> WKWebView {
self.controller.webView

View File

@@ -1,19 +1,21 @@
import ClawdisKit
import Network
import Observation
import SwiftUI
import UIKit
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
@Observable
private final class ConnectStatusStore {
var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@EnvironmentObject private var bridgeController: BridgeConnectionController
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(BridgeConnectionController.self) private var bridgeController: BridgeConnectionController
@Environment(\.dismiss) private var dismiss
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@@ -25,7 +27,7 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790
@AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -264,6 +266,7 @@ struct SettingsTab: View {
defer { self.connectingBridgeID = nil }
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -281,9 +284,8 @@ struct SettingsTab: View {
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})
@@ -330,6 +332,7 @@ struct SettingsTab: View {
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
do {
let statusStore = self.connectStatus
let existing = KeychainStore.loadString(
service: "com.steipete.clawdis.bridge",
account: self.keychainAccount())
@@ -347,9 +350,8 @@ struct SettingsTab: View {
endpoint: endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
statusStore.text = status
}
})

View File

@@ -1,8 +1,8 @@
import SwiftUI
struct VoiceTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@EnvironmentObject private var voiceWake: VoiceWakeManager
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
var body: some View {

View File

@@ -1,5 +1,6 @@
import AVFAudio
import Foundation
import Observation
import Speech
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
@@ -76,12 +77,13 @@ extension AVAudioPCMBuffer {
}
@MainActor
final class VoiceWakeManager: NSObject, ObservableObject {
@Published var isEnabled: Bool = false
@Published var isListening: Bool = false
@Published var statusText: String = "Off"
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@Published var lastTriggeredCommand: String?
@Observable
final class VoiceWakeManager: NSObject {
var isEnabled: Bool = false
var isListening: Bool = false
var statusText: String = "Off"
var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
var lastTriggeredCommand: String?
private let audioEngine = AVAudioEngine()
private var speechRecognizer: SFSpeechRecognizer?