feat(ios): add always-on status overlay

This commit is contained in:
Peter Steinberger
2025-12-14 03:00:45 +00:00
parent 7b1163f75c
commit d7165b4720
5 changed files with 245 additions and 82 deletions

View File

@@ -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<Void, Never>?
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)
}
}