feat(ios): add always-on status overlay
This commit is contained in:
@@ -2,7 +2,11 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RootCanvas: View {
|
struct RootCanvas: View {
|
||||||
@EnvironmentObject private var appModel: NodeAppModel
|
@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 presentedSheet: PresentedSheet?
|
||||||
|
@State private var voiceWakeToastText: String?
|
||||||
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
private enum PresentedSheet: Identifiable {
|
private enum PresentedSheet: Identifiable {
|
||||||
case settings
|
case settings
|
||||||
@@ -34,6 +38,22 @@ struct RootCanvas: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.trailing, 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
|
.sheet(item: self.$presentedSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .settings:
|
case .settings:
|
||||||
@@ -43,6 +63,46 @@ struct RootCanvas: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(.dark)
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,82 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
|
||||||
|
|
||||||
struct RootTabs: View {
|
struct RootTabs: View {
|
||||||
@EnvironmentObject private var appModel: NodeAppModel
|
@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<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: self.$selectedTab) {
|
||||||
ScreenTab()
|
ScreenTab()
|
||||||
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
VoiceTab()
|
VoiceTab()
|
||||||
.tabItem { Label("Voice", systemImage: "mic") }
|
.tabItem { Label("Voice", systemImage: "mic") }
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
SettingsTab()
|
SettingsTab()
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||||
|
.tag(2)
|
||||||
}
|
}
|
||||||
.background(TabBarControllerAccessor { tabBarController in
|
.overlay(alignment: .topLeading) {
|
||||||
guard let item = tabBarController.tabBar.items?[Self.settingsTabIndex] else { return }
|
StatusPill(
|
||||||
item.badgeValue = ""
|
bridge: self.bridgeStatus,
|
||||||
item.badgeColor = self.settingsBadgeColor
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||||
})
|
onTap: { self.selectedTab = 2 })
|
||||||
.onAppear { self.updateConnectingPulse(for: self.bridgeIndicatorState) }
|
.padding(.leading, 10)
|
||||||
.onChange(of: self.bridgeIndicatorState) { _, newValue in
|
.safeAreaPadding(.top, 10)
|
||||||
self.updateConnectingPulse(for: newValue)
|
}
|
||||||
|
.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 {
|
private var bridgeStatus: StatusPill.BridgeState {
|
||||||
case connected
|
|
||||||
case connecting
|
|
||||||
case disconnected
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let settingsTabIndex = 2
|
|
||||||
|
|
||||||
private var bridgeIndicatorState: BridgeIndicatorState {
|
|
||||||
if self.appModel.bridgeServerName != nil { return .connected }
|
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
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
91
apps/ios/Sources/Status/StatusPill.swift
Normal file
91
apps/ios/Sources/Status/StatusPill.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/ios/Sources/Status/VoiceWakeToast.swift
Normal file
32
apps/ios/Sources/Status/VoiceWakeToast.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
@Published var isListening: Bool = false
|
@Published var isListening: Bool = false
|
||||||
@Published var statusText: String = "Off"
|
@Published var statusText: String = "Off"
|
||||||
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@Published var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
@Published var lastTriggeredCommand: String?
|
||||||
|
|
||||||
private let audioEngine = AVAudioEngine()
|
private let audioEngine = AVAudioEngine()
|
||||||
private var speechRecognizer: SFSpeechRecognizer?
|
private var speechRecognizer: SFSpeechRecognizer?
|
||||||
@@ -314,6 +315,7 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
if cmd == self.lastDispatched { return }
|
if cmd == self.lastDispatched { return }
|
||||||
self.lastDispatched = cmd
|
self.lastDispatched = cmd
|
||||||
|
self.lastTriggeredCommand = cmd
|
||||||
self.statusText = "Triggered"
|
self.statusText = "Triggered"
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
|
|||||||
Reference in New Issue
Block a user