import SwiftUI import UIKit import OpenClawProtocol struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @Environment(GatewayConnectionController.self) private var gatewayController @Environment(VoiceWakeManager.self) private var voiceWake @Environment(\.colorScheme) private var systemColorScheme @Environment(\.scenePhase) private var scenePhase @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false @State private var presentedSheet: PresentedSheet? @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? @State private var showOnboarding: Bool = false @State private var onboardingAllowSkip: Bool = true @State private var didEvaluateOnboarding: Bool = false @State private var didAutoOpenSettings: Bool = false private enum PresentedSheet: Identifiable { case settings case chat case quickSetup var id: Int { switch self { case .settings: 0 case .chat: 1 case .quickSetup: 2 } } } enum StartupPresentationRoute: Equatable { case none case onboarding case settings } static func startupPresentationRoute( gatewayConnected: Bool, hasConnectedOnce: Bool, onboardingComplete: Bool, hasExistingGatewayConfig: Bool, shouldPresentOnLaunch: Bool) -> StartupPresentationRoute { if gatewayConnected { return .none } // On first run or explicit launch onboarding state, onboarding always wins. if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { return .onboarding } // Settings auto-open is a recovery path for previously-connected installs only. if !hasExistingGatewayConfig { return .settings } return .none } static func shouldPresentQuickSetup( quickSetupDismissed: Bool, showOnboarding: Bool, hasPresentedSheet: Bool, gatewayConnected: Bool, hasExistingGatewayConfig: Bool, discoveredGatewayCount: Int) -> Bool { guard !quickSetupDismissed else { return false } guard !showOnboarding else { return false } guard !hasPresentedSheet else { return false } guard !gatewayConnected else { return false } // If a gateway target is already configured (manual or last-known), skip quick setup. guard !hasExistingGatewayConfig else { return false } return discoveredGatewayCount > 0 } var body: some View { ZStack { CanvasContent( systemColorScheme: self.systemColorScheme, gatewayStatus: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, voiceWakeToastText: self.voiceWakeToastText, cameraHUDText: self.appModel.cameraHUDText, cameraHUDKind: self.appModel.cameraHUDKind, openChat: { self.presentedSheet = .chat }, openSettings: { self.presentedSheet = .settings }) .preferredColorScheme(.dark) if self.appModel.cameraFlashNonce != 0 { CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce) } } .gatewayTrustPromptAlert() .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: SettingsTab() .environment(self.appModel) .environment(self.appModel.voiceWake) .environment(self.gatewayController) case .chat: ChatSheet( // Chat RPCs run on the operator session (read/write scopes). gateway: self.appModel.operatorSession, sessionKey: self.appModel.chatSessionKey, agentName: self.appModel.activeAgentName, userAccent: self.appModel.seamColor) case .quickSetup: GatewayQuickSetupSheet() .environment(self.appModel) .environment(self.gatewayController) } } .fullScreenCover(isPresented: self.$showOnboarding) { OnboardingWizardView( allowSkip: self.onboardingAllowSkip, onClose: { self.showOnboarding = false }) .environment(self.appModel) .environment(self.appModel.voiceWake) .environment(self.gatewayController) } .onAppear { self.updateIdleTimer() } .onAppear { self.updateHomeCanvasState() } .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, newValue in self.updateIdleTimer() self.updateHomeCanvasState() guard newValue == .active else { return } Task { await self.appModel.refreshGatewayOverviewIfConnected() await MainActor.run { self.updateHomeCanvasState() } } } .onAppear { self.maybeShowQuickSetup() } .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() self.updateHomeCanvasState() } .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() self.updateHomeCanvasState() } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.showOnboarding = false } } .onChange(of: self.onboardingRequestID) { _, _ in self.evaluateOnboardingPresentation(force: true) } .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() self.updateHomeCanvasState() } .onChange(of: self.appModel.homeCanvasRevision) { _, _ in self.updateHomeCanvasState() } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.onboardingComplete = true self.hasConnectedOnce = true OnboardingStateStore.markCompleted(mode: nil) } self.maybeAutoOpenSettings() } .onChange(of: self.appModel.openChatRequestID) { _, _ in self.presentedSheet = .chat } .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in guard let newValue else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } self.toastDismissTask?.cancel() withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { self.voiceWakeToastText = trimmed } self.toastDismissTask = Task { try? await Task.sleep(nanoseconds: 2_300_000_000) await MainActor.run { withAnimation(.easeOut(duration: 0.25)) { self.voiceWakeToastText = nil } } } } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false self.toastDismissTask?.cancel() self.toastDismissTask = nil } } private var gatewayStatus: StatusPill.GatewayState { GatewayStatusBuilder.build(appModel: self.appModel) } private func updateIdleTimer() { UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep) } private func updateCanvasDebugStatus() { self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled) guard self.canvasDebugStatusEnabled else { return } let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } private func updateHomeCanvasState() { let payload = self.makeHomeCanvasPayload() guard let data = try? JSONEncoder().encode(payload), let json = String(data: data, encoding: .utf8) else { self.appModel.screen.updateHomeCanvasState(json: nil) return } self.appModel.screen.updateHomeCanvasState(json: json) } private func makeHomeCanvasPayload() -> HomeCanvasPayload { let gatewayName = self.normalized(self.appModel.gatewayServerName) let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress) let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway" let activeAgentID = self.resolveActiveAgentID() let agents = self.homeCanvasAgents(activeAgentID: activeAgentID) switch self.gatewayStatus { case .connected: return HomeCanvasPayload( gatewayState: "connected", eyebrow: "Connected to \(gatewayLabel)", title: "Your agents are ready", subtitle: "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", gatewayLabel: gatewayLabel, activeAgentName: self.appModel.activeAgentName, activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC", activeAgentCaption: "Selected on this phone", agentCount: agents.count, agents: Array(agents.prefix(6)), footer: "The overview refreshes on reconnect and when the app returns to foreground.") case .connecting: return HomeCanvasPayload( gatewayState: "connecting", eyebrow: "Reconnecting", title: "OpenClaw is syncing back up", subtitle: "The gateway session is coming back online. " + "Agent shortcuts should settle automatically in a moment.", gatewayLabel: gatewayLabel, activeAgentName: self.appModel.activeAgentName, activeAgentBadge: "OC", activeAgentCaption: "Gateway session in progress", agentCount: agents.count, agents: Array(agents.prefix(4)), footer: "If the gateway is reachable, reconnect should complete without intervention.") case .error, .disconnected: return HomeCanvasPayload( gatewayState: self.gatewayStatus == .error ? "error" : "offline", eyebrow: "Welcome to OpenClaw", title: "Your phone stays quiet until it is needed", subtitle: "Pair this device to your gateway to wake it only for real work, " + "keep a live agent overview handy, and avoid battery-draining background loops.", gatewayLabel: gatewayLabel, activeAgentName: "Main", activeAgentBadge: "OC", activeAgentCaption: "Connect to load your agents", agentCount: agents.count, agents: Array(agents.prefix(4)), footer: "When connected, the gateway can wake the phone with a silent push " + "instead of holding an always-on session.") } } private func resolveActiveAgentID() -> String { let selected = self.normalized(self.appModel.selectedAgentId) ?? "" if !selected.isEmpty { return selected } return self.resolveDefaultAgentID() } private func resolveDefaultAgentID() -> String { self.normalized(self.appModel.gatewayDefaultAgentId) ?? "" } private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] { let defaultAgentID = self.resolveDefaultAgentID() let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID return HomeCanvasAgentCard( id: agent.id, name: self.homeCanvasName(for: agent), badge: self.homeCanvasBadge(for: agent), caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"), isActive: isActive) } return cards.sorted { lhs, rhs in if lhs.isActive != rhs.isActive { return lhs.isActive } return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } } private func homeCanvasName(for agent: AgentSummary) -> String { self.normalized(agent.name) ?? agent.id } private func homeCanvasBadge(for agent: AgentSummary) -> String { if let identity = agent.identity, let emoji = identity["emoji"]?.value as? String, let normalizedEmoji = self.normalized(emoji) { return normalizedEmoji } let words = self.homeCanvasName(for: agent) .split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) .prefix(2) let initials = words.compactMap { $0.first }.map(String.init).joined() if !initials.isEmpty { return initials.uppercased() } return "OC" } private func normalized(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func evaluateOnboardingPresentation(force: Bool) { if force { self.onboardingAllowSkip = true self.showOnboarding = true return } guard !self.didEvaluateOnboarding else { return } self.didEvaluateOnboarding = true let route = Self.startupPresentationRoute( gatewayConnected: self.appModel.gatewayServerName != nil, hasConnectedOnce: self.hasConnectedOnce, onboardingComplete: self.onboardingComplete, hasExistingGatewayConfig: self.hasExistingGatewayConfig(), shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) switch route { case .none: break case .onboarding: self.onboardingAllowSkip = true self.showOnboarding = true case .settings: self.didAutoOpenSettings = true self.presentedSheet = .settings } } private func hasExistingGatewayConfig() -> Bool { if self.appModel.activeGatewayConnectConfig != nil { return true } if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true } let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) if !preferredStableID.isEmpty { return true } let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) return self.manualGatewayEnabled && !manualHost.isEmpty } private func maybeAutoOpenSettings() { guard !self.didAutoOpenSettings else { return } guard !self.showOnboarding else { return } let route = Self.startupPresentationRoute( gatewayConnected: self.appModel.gatewayServerName != nil, hasConnectedOnce: self.hasConnectedOnce, onboardingComplete: self.onboardingComplete, hasExistingGatewayConfig: self.hasExistingGatewayConfig(), shouldPresentOnLaunch: false) guard route == .settings else { return } self.didAutoOpenSettings = true self.presentedSheet = .settings } private func maybeShowQuickSetup() { let shouldPresent = Self.shouldPresentQuickSetup( quickSetupDismissed: self.quickSetupDismissed, showOnboarding: self.showOnboarding, hasPresentedSheet: self.presentedSheet != nil, gatewayConnected: self.appModel.gatewayServerName != nil, hasExistingGatewayConfig: self.hasExistingGatewayConfig(), discoveredGatewayCount: self.gatewayController.gateways.count) guard shouldPresent else { return } self.presentedSheet = .quickSetup } } private struct HomeCanvasPayload: Codable { var gatewayState: String var eyebrow: String var title: String var subtitle: String var gatewayLabel: String var activeAgentName: String var activeAgentBadge: String var activeAgentCaption: String var agentCount: Int var agents: [HomeCanvasAgentCard] var footer: String } private struct HomeCanvasAgentCard: Codable { var id: String var name: String var badge: String var caption: String var isActive: Bool } private struct CanvasContent: View { @Environment(NodeAppModel.self) private var appModel @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true @State private var showGatewayActions: Bool = false var systemColorScheme: ColorScheme var gatewayStatus: StatusPill.GatewayState var voiceWakeEnabled: Bool var voiceWakeToastText: String? var cameraHUDText: String? var cameraHUDKind: NodeAppModel.CameraHUDKind? var openChat: () -> Void var openSettings: () -> Void private var brightenButtons: Bool { self.systemColorScheme == .light } private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled } var body: some View { ZStack { ScreenTab() } .overlay(alignment: .center) { if self.talkActive { TalkOrbOverlay() .transition(.opacity) } } .safeAreaInset(edge: .bottom, spacing: 0) { HomeToolbar( gateway: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, activity: self.statusActivity, brighten: self.brightenButtons, talkButtonEnabled: self.talkButtonEnabled, talkActive: self.talkActive, talkTint: self.appModel.seamColor, onStatusTap: { if self.gatewayStatus == .connected { self.showGatewayActions = true } else { self.openSettings() } }, onChatTap: { self.openChat() }, onTalkTap: { let next = !self.talkActive self.talkEnabled = next self.appModel.setTalkEnabled(next) }, onSettingsTap: { self.openSettings() }) } .overlay(alignment: .topLeading) { if let voiceWakeToastText, !voiceWakeToastText.isEmpty { VoiceWakeToast( command: voiceWakeToastText, brighten: self.brightenButtons) .padding(.leading, 10) .safeAreaPadding(.top, 58) .transition(.move(edge: .top).combined(with: .opacity)) } } .gatewayActionsDialog( isPresented: self.$showGatewayActions, onDisconnect: { self.appModel.disconnectGateway() }, onOpenSettings: { self.openSettings() }) .onAppear { // Keep the runtime talk state aligned with persisted toggle state on cold launch. if self.talkEnabled != self.appModel.talkMode.isEnabled { self.appModel.setTalkEnabled(self.talkEnabled) } } } private var statusActivity: StatusPill.Activity? { StatusActivityBuilder.build( appModel: self.appModel, voiceWakeEnabled: self.voiceWakeEnabled, cameraHUDText: self.cameraHUDText, cameraHUDKind: self.cameraHUDKind) } } private struct CameraFlashOverlay: View { var nonce: Int @State private var opacity: CGFloat = 0 @State private var task: Task? var body: some View { Color.white .opacity(self.opacity) .ignoresSafeArea() .allowsHitTesting(false) .onChange(of: self.nonce) { _, _ in self.task?.cancel() self.task = Task { @MainActor in withAnimation(.easeOut(duration: 0.08)) { self.opacity = 0.85 } try? await Task.sleep(nanoseconds: 110_000_000) withAnimation(.easeOut(duration: 0.32)) { self.opacity = 0 } } } } }