refactor(observation): migrate SwiftUI state
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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>?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user