Spaces:
Paused
Paused
| import SwiftUI | |
| struct RootTabs: View { | |
| (NodeAppModel.self) private var appModel | |
| (VoiceWakeManager.self) private var voiceWake | |
| (VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false | |
| private var selectedTab: Int = 0 | |
| private var voiceWakeToastText: String? | |
| private var toastDismissTask: Task<Void, Never>? | |
| var body: some View { | |
| TabView(selection: self.$selectedTab) { | |
| ScreenTab() | |
| .tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") } | |
| .tag(0) | |
| VoiceTab() | |
| .tabItem { Label("Voice", systemImage: "mic") } | |
| .tag(1) | |
| SettingsTab() | |
| .tabItem { Label("Settings", systemImage: "gearshape") } | |
| .tag(2) | |
| } | |
| .overlay(alignment: .topLeading) { | |
| StatusPill( | |
| gateway: self.gatewayStatus, | |
| voiceWakeEnabled: self.voiceWakeEnabled, | |
| activity: self.statusActivity, | |
| onTap: { self.selectedTab = 2 }) | |
| .padding(.leading, 10) | |
| .safeAreaPadding(.top, 10) | |
| } | |
| .overlay(alignment: .topLeading) { | |
| if let voiceWakeToastText, !voiceWakeToastText.isEmpty { | |
| VoiceWakeToast(command: voiceWakeToastText) | |
| .padding(.leading, 10) | |
| .safeAreaPadding(.top, 58) | |
| .transition(.move(edge: .top).combined(with: .opacity)) | |
| } | |
| } | |
| .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 { | |
| 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 var statusActivity: StatusPill.Activity? { | |
| // Keep the top pill consistent across tabs (camera + voice wake + pairing states). | |
| 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 = self.appModel.cameraHUDText, | |
| let cameraHUDKind = self.appModel.cameraHUDKind, | |
| !cameraHUDText.isEmpty | |
| { | |
| 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 | |
| } | |
| } | |