import SwiftUI import UIKit 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 } 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() .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.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } .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() } .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } .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() } .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 { if self.appModel.gatewayServerName != nil { return .connected } let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) if text.localizedCaseInsensitiveContains("connecting") || text.localizedCaseInsensitiveContains("reconnecting") { return .connecting } if text.localizedCaseInsensitiveContains("error") { return .error } return .disconnected } 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 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 GatewaySettingsStore.loadLastGatewayConnection() != nil { 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() { guard !self.quickSetupDismissed else { return } guard !self.showOnboarding else { return } guard self.presentedSheet == nil else { return } guard self.appModel.gatewayServerName == nil else { return } guard !self.gatewayController.gateways.isEmpty else { return } self.presentedSheet = .quickSetup } } 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 } var body: some View { ZStack(alignment: .topTrailing) { ScreenTab() VStack(spacing: 10) { OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) { self.openChat() } .accessibilityLabel("Chat") if self.talkButtonEnabled { // Talk mode lives on a side bubble so it doesn't get buried in settings. OverlayButton( systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", brighten: self.brightenButtons, tint: self.appModel.seamColor, isActive: self.appModel.talkMode.isEnabled) { let next = !self.appModel.talkMode.isEnabled self.talkEnabled = next self.appModel.setTalkEnabled(next) } .accessibilityLabel("Talk Mode") } OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { self.openSettings() } .accessibilityLabel("Settings") } .padding(.top, 10) .padding(.trailing, 10) } .overlay(alignment: .center) { if self.appModel.talkMode.isEnabled { TalkOrbOverlay() .transition(.opacity) } } .overlay(alignment: .topLeading) { StatusPill( gateway: self.gatewayStatus, voiceWakeEnabled: self.voiceWakeEnabled, activity: self.statusActivity, brighten: self.brightenButtons, onTap: { if self.gatewayStatus == .connected { self.showGatewayActions = true } else { self.openSettings() } }) .padding(.leading, 10) .safeAreaPadding(.top, 10) } .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)) } } .confirmationDialog( "Gateway", isPresented: self.$showGatewayActions, titleVisibility: .visible) { Button("Disconnect", role: .destructive) { self.appModel.disconnectGateway() } Button("Open Settings") { self.openSettings() } Button("Cancel", role: .cancel) {} } message: { Text("Disconnect from the gateway?") } } private var statusActivity: StatusPill.Activity? { // Status pill owns transient activity state so it doesn't overlap the connection indicator. if self.appModel.isBackgrounded { return StatusPill.Activity( title: "Foreground required", systemImage: "exclamationmark.triangle.fill", tint: .orange) } let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) let gatewayLower = gatewayStatus.lowercased() if gatewayLower.contains("repair") { return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) } if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") } // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. if self.appModel.screenRecordActive { return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) } if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { let systemImage: String let tint: Color? switch cameraHUDKind { case .photo: systemImage = "camera.fill" tint = nil case .recording: systemImage = "video.fill" tint = .red case .success: systemImage = "checkmark.circle.fill" tint = .green case .error: systemImage = "exclamationmark.triangle.fill" tint = .red } return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) } if self.voiceWakeEnabled { let voiceStatus = self.appModel.voiceWake.statusText if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) } if voiceStatus == "Paused" { // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. if self.appModel.talkMode.isEnabled { return nil } let suffix = self.appModel.isBackgrounded ? " (background)" : "" return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") } } return nil } } private struct OverlayButton: View { let systemImage: String let brighten: Bool var tint: Color? var isActive: Bool = false let action: () -> Void var body: some View { Button(action: self.action) { Image(systemName: self.systemImage) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) .padding(10) .background { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.ultraThinMaterial) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill( LinearGradient( colors: [ .white.opacity(self.brighten ? 0.26 : 0.18), .white.opacity(self.brighten ? 0.08 : 0.04), .clear, ], startPoint: .topLeading, endPoint: .bottomTrailing)) .blendMode(.overlay) } .overlay { if let tint { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill( LinearGradient( colors: [ tint.opacity(self.isActive ? 0.22 : 0.14), tint.opacity(self.isActive ? 0.10 : 0.06), .clear, ], startPoint: .topLeading, endPoint: .bottomTrailing)) .blendMode(.overlay) } } .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder( (self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)), lineWidth: self.isActive ? 0.7 : 0.5) } .shadow(color: .black.opacity(0.35), radius: 12, y: 6) } } .buttonStyle(.plain) } } 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 } } } } }