diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 99a3e2e31..95d635dfb 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -2,7 +2,11 @@ import SwiftUI struct RootCanvas: View { @EnvironmentObject private var appModel: NodeAppModel + @EnvironmentObject private var voiceWake: VoiceWakeManager + @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @State private var presentedSheet: PresentedSheet? + @State private var voiceWakeToastText: String? + @State private var toastDismissTask: Task? private enum PresentedSheet: Identifiable { case settings @@ -34,6 +38,22 @@ struct RootCanvas: View { .padding(.top, 10) .padding(.trailing, 10) } + .overlay(alignment: .topLeading) { + StatusPill( + bridge: self.bridgeStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + onTap: { self.presentedSheet = .settings }) + .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)) + } + } .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: @@ -43,6 +63,46 @@ struct RootCanvas: View { } } .preferredColorScheme(.dark) + .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 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 } } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 7d4425e21..169234aa0 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -1,104 +1,82 @@ import SwiftUI -import UIKit struct RootTabs: View { @EnvironmentObject private var appModel: NodeAppModel - @State private var isConnectingPulse: Bool = false + @EnvironmentObject private var voiceWake: VoiceWakeManager + @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false + @State private var selectedTab: Int = 0 + @State private var voiceWakeToastText: String? + @State private var toastDismissTask: Task? var body: some View { - TabView { + 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) } - .background(TabBarControllerAccessor { tabBarController in - guard let item = tabBarController.tabBar.items?[Self.settingsTabIndex] else { return } - item.badgeValue = "" - item.badgeColor = self.settingsBadgeColor - }) - .onAppear { self.updateConnectingPulse(for: self.bridgeIndicatorState) } - .onChange(of: self.bridgeIndicatorState) { _, newValue in - self.updateConnectingPulse(for: newValue) + .overlay(alignment: .topLeading) { + StatusPill( + bridge: self.bridgeStatus, + voiceWakeEnabled: self.voiceWakeEnabled, + 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 enum BridgeIndicatorState { - case connected - case connecting - case disconnected - } - - private static let settingsTabIndex = 2 - - private var bridgeIndicatorState: BridgeIndicatorState { + private var bridgeStatus: StatusPill.BridgeState { if self.appModel.bridgeServerName != nil { return .connected } - if self.appModel.bridgeStatusText.localizedCaseInsensitiveContains("connecting") { return .connecting } + + 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 var settingsBadgeColor: UIColor { - switch self.bridgeIndicatorState { - case .connected: - UIColor.systemGreen - case .connecting: - UIColor.systemYellow.withAlphaComponent(self.isConnectingPulse ? 1.0 : 0.6) - case .disconnected: - UIColor.systemRed - } - } - - private func updateConnectingPulse(for state: BridgeIndicatorState) { - guard state == .connecting else { - withAnimation(.easeOut(duration: 0.2)) { self.isConnectingPulse = false } - return - } - - guard !self.isConnectingPulse else { return } - withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { - self.isConnectingPulse = true - } - } -} - -private struct TabBarControllerAccessor: UIViewControllerRepresentable { - let onResolve: (UITabBarController) -> Void - - func makeUIViewController(context: Context) -> ResolverViewController { - ResolverViewController(onResolve: self.onResolve) - } - - func updateUIViewController(_ uiViewController: ResolverViewController, context: Context) { - uiViewController.onResolve = self.onResolve - uiViewController.resolveIfPossible() - } -} - -private final class ResolverViewController: UIViewController { - var onResolve: (UITabBarController) -> Void - - init(onResolve: @escaping (UITabBarController) -> Void) { - self.onResolve = onResolve - super.init(nibName: nil, bundle: nil) - self.view.isHidden = true - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - self.resolveIfPossible() - } - - func resolveIfPossible() { - guard let tabBarController = self.tabBarController else { return } - self.onResolve(tabBarController) - } } diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift new file mode 100644 index 000000000..979df3c09 --- /dev/null +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct StatusPill: View { + enum BridgeState: Equatable { + case connected + case connecting + case error + case disconnected + + var title: String { + switch self { + case .connected: "Connected" + case .connecting: "Connecting…" + case .error: "Error" + case .disconnected: "Offline" + } + } + + var color: Color { + switch self { + case .connected: .green + case .connecting: .yellow + case .error: .red + case .disconnected: .gray + } + } + } + + var bridge: BridgeState + var voiceWakeEnabled: Bool + var onTap: () -> Void + + @State private var pulse: Bool = false + + var body: some View { + Button(action: self.onTap) { + HStack(spacing: 10) { + HStack(spacing: 8) { + Circle() + .fill(self.bridge.color) + .frame(width: 9, height: 9) + .scaleEffect(self.bridge == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) + .opacity(self.bridge == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) + + Text(self.bridge.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + } + + Divider() + .frame(height: 14) + .opacity(0.35) + + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(0.18), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Status") + .accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")") + .onAppear { self.updatePulse(for: self.bridge) } + .onChange(of: self.bridge) { _, newValue in + self.updatePulse(for: newValue) + } + } + + private func updatePulse(for bridge: BridgeState) { + guard bridge == .connecting else { + withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } + return + } + + guard !self.pulse else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + self.pulse = true + } + } +} diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift new file mode 100644 index 000000000..942a7c0e9 --- /dev/null +++ b/apps/ios/Sources/Status/VoiceWakeToast.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct VoiceWakeToast: View { + var command: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "mic.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + + Text(self.command) + .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(0.18), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.25), radius: 12, y: 6) + } + .accessibilityLabel("Voice Wake") + .accessibilityValue(self.command) + } +} diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index cba7e697f..aef96b06b 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -81,6 +81,7 @@ final class VoiceWakeManager: NSObject, ObservableObject { @Published var isListening: Bool = false @Published var statusText: String = "Off" @Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @Published var lastTriggeredCommand: String? private let audioEngine = AVAudioEngine() private var speechRecognizer: SFSpeechRecognizer? @@ -314,6 +315,7 @@ final class VoiceWakeManager: NSObject, ObservableObject { if cmd == self.lastDispatched { return } self.lastDispatched = cmd + self.lastTriggeredCommand = cmd self.statusText = "Triggered" Task { [weak self] in