feat(ios): add always-on status overlay
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user