Spaces:
Paused
Paused
| import SwiftUI | |
| import UIKit | |
| struct RootCanvas: View { | |
| (NodeAppModel.self) private var appModel | |
| (VoiceWakeManager.self) private var voiceWake | |
| (\.colorScheme) private var systemColorScheme | |
| (\.scenePhase) private var scenePhase | |
| (VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false | |
| ("screen.preventSleep") private var preventSleep: Bool = true | |
| ("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false | |
| private var presentedSheet: PresentedSheet? | |
| private var voiceWakeToastText: String? | |
| private var toastDismissTask: Task<Void, Never>? | |
| private enum PresentedSheet: Identifiable { | |
| case settings | |
| case chat | |
| var id: Int { | |
| switch self { | |
| case .settings: 0 | |
| case .chat: 1 | |
| } | |
| } | |
| } | |
| 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) | |
| } | |
| } | |
| .sheet(item: self.$presentedSheet) { sheet in | |
| switch sheet { | |
| case .settings: | |
| SettingsTab() | |
| case .chat: | |
| ChatSheet( | |
| gateway: self.appModel.gatewaySession, | |
| sessionKey: self.appModel.mainSessionKey, | |
| userAccent: self.appModel.seamColor) | |
| } | |
| } | |
| .onAppear { self.updateIdleTimer() } | |
| .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } | |
| .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } | |
| .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.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } | |
| .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 struct CanvasContent: View { | |
| (NodeAppModel.self) private var appModel | |
| ("talk.enabled") private var talkEnabled: Bool = false | |
| ("talk.button.enabled") private var talkButtonEnabled: Bool = true | |
| 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: { | |
| 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)) | |
| } | |
| } | |
| } | |
| 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" { | |
| 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 | |
| private var opacity: CGFloat = 0 | |
| private var task: Task<Void, Never>? | |
| var body: some View { | |
| Color.white | |
| .opacity(self.opacity) | |
| .ignoresSafeArea() | |
| .allowsHitTesting(false) | |
| .onChange(of: self.nonce) { _, _ in | |
| self.task?.cancel() | |
| self.task = Task { 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 | |
| } | |
| } | |
| } | |
| } | |
| } | |