import SwiftUI import UIKit struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel @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 @State private var presentedSheet: PresentedSheet? @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? 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, bridgeStatus: self.bridgeStatus, 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(bridge: self.appModel.bridgeSession) } } .onAppear { self.updateIdleTimer() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } .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 bridgeStatus: StatusPill.BridgeState { if self.appModel.bridgeServerName != nil { return .connected } let text = self.appModel.bridgeStatusText.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 struct CanvasContent: View { var systemColorScheme: ColorScheme var bridgeStatus: StatusPill.BridgeState 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") OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) { self.openSettings() } .accessibilityLabel("Settings") } .padding(.top, 10) .padding(.trailing, 10) } .overlay(alignment: .topLeading) { StatusPill( bridge: self.bridgeStatus, voiceWakeEnabled: self.voiceWakeEnabled, 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)) } } .overlay(alignment: .topLeading) { if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { CameraCaptureToast( text: cameraHUDText, kind: self.mapCameraKind(cameraHUDKind), brighten: self.brightenButtons) .padding(SwiftUI.Edge.Set.leading, 10) .safeAreaPadding(SwiftUI.Edge.Set.top, 106) .transition( AnyTransition.move(edge: SwiftUI.Edge.top) .combined(with: AnyTransition.opacity)) } } } private func mapCameraKind(_ kind: NodeAppModel.CameraHUDKind) -> CameraCaptureToast.Kind { switch kind { case .photo: .photo case .recording: .recording case .success: .success case .error: .error } } } private struct OverlayButton: View { let systemImage: String let brighten: Bool let action: () -> Void var body: some View { Button(action: self.action) { Image(systemName: self.systemImage) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.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 { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 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 } } } } } private struct CameraCaptureToast: View { enum Kind { case photo case recording case success case error } var text: String var kind: Kind var brighten: Bool = false var body: some View { HStack(spacing: 10) { self.icon .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) Text(self.text) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) } .padding(.vertical, 10) .padding(.horizontal, 12) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(.ultraThinMaterial) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5) } .shadow(color: .black.opacity(0.25), radius: 12, y: 6) } .accessibilityLabel("Camera") .accessibilityValue(self.text) } @ViewBuilder private var icon: some View { switch self.kind { case .photo: Image(systemName: "camera.fill") case .recording: Image(systemName: "record.circle.fill") .symbolRenderingMode(.palette) .foregroundStyle(.red, .primary) case .success: Image(systemName: "checkmark.circle.fill") case .error: Image(systemName: "exclamationmark.triangle.fill") } } }